From e290769df10ceb7fd0176c9c8c2cca2d2d545c86 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:28:34 +0900 Subject: [PATCH 01/36] [client] Take the graphical session answer from the caller instead of the daemon environment (#7187) --- client/cmd/login.go | 16 +++------- client/cmd/up.go | 4 +-- client/proto/daemon.pb.go | 44 ++++++++++++++++++++------ client/proto/daemon.proto | 8 +++++ client/server/server.go | 15 +++------ client/ssh/common.go | 5 +-- client/ui/authsession/service.go | 3 +- client/ui/services/connection.go | 9 +++--- util/common.go | 53 +++++++++++++++++++++++++++++++- util/session_test.go | 50 ++++++++++++++++++++++++++++++ 10 files changed, 164 insertions(+), 43 deletions(-) create mode 100644 util/session_test.go diff --git a/client/cmd/login.go b/client/cmd/login.go index a53cb6d5f..6aa019896 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/user" - "runtime" "strings" log "github.com/sirupsen/logrus" @@ -121,7 +120,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, ManagementUrl: managementURL, - IsUnixDesktopClient: isUnixRunningDesktop(), + IsUnixDesktopClient: util.HasGraphicalSession(), Hostname: hostName, DnsLabels: dnsLabelsReq, ProfileName: &handle, @@ -189,7 +188,8 @@ func doExtendSession(ctx context.Context, cmd *cobra.Command) error { client := proto.NewDaemonServiceClient(conn) - req := &proto.RequestExtendAuthSessionRequest{} + // the CLI runs in the user's session, the daemon does not: tell it what we can see + req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()} // Pre-fill the IdP login hint from the active profile so the user // doesn't have to retype their email. Best-effort: we still proceed // without a hint if the lookup fails. @@ -408,7 +408,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro hint = profileState.Email } - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint) + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint) if err != nil { return nil, err } @@ -458,14 +458,6 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro } } -// isUnixRunningDesktop checks if a Linux OS is running desktop environment -func isUnixRunningDesktop() bool { - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - return false - } - return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" -} - func setEnvAndFlags(cmd *cobra.Command) error { SetFlagsFromEnvVars(rootCmd) diff --git a/client/cmd/up.go b/client/cmd/up.go index 142bcf6bd..9f4fa8c33 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -21,8 +21,8 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" @@ -626,7 +626,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte NatExternalIPs: natExternalIPs, CleanNATExternalIPs: natExternalIPs != nil && len(natExternalIPs) == 0, CustomDNSAddress: customDNSAddressConverted, - IsUnixDesktopClient: isUnixRunningDesktop(), + IsUnixDesktopClient: util.HasGraphicalSession(), Hostname: hostName, ExtraIFaceBlacklist: extraIFaceBlackList, DnsLabels: dnsLabels, diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 83243be49..b438a310a 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -5628,9 +5628,13 @@ func (x *GetPeerSSHHostKeyResponse) GetFound() bool { type RequestJWTAuthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // hint for OIDC login_hint parameter (typically email address) - Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RequestJWTAuthRequest) Reset() { @@ -5670,6 +5674,13 @@ func (x *RequestJWTAuthRequest) GetHint() string { return "" } +func (x *RequestJWTAuthRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + // RequestJWTAuthResponse contains authentication flow information type RequestJWTAuthResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5894,9 +5905,13 @@ type RequestExtendAuthSessionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Optional OIDC login_hint (typically the user's email) to pre-fill the // IdP login form. - Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RequestExtendAuthSessionRequest) Reset() { @@ -5936,6 +5951,13 @@ func (x *RequestExtendAuthSessionRequest) GetHint() string { return "" } +func (x *RequestExtendAuthSessionRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + // RequestExtendAuthSessionResponse carries the verification URI the UI // should open in a browser. The daemon retains the flow state and resolves // it via WaitExtendAuthSession. @@ -7503,9 +7525,10 @@ const file_daemon_proto_rawDesc = "" + "sshHostKey\x12\x16\n" + "\x06peerIP\x18\x02 \x01(\tR\x06peerIP\x12\x1a\n" + "\bpeerFQDN\x18\x03 \x01(\tR\bpeerFQDN\x12\x14\n" + - "\x05found\x18\x04 \x01(\bR\x05found\"9\n" + + "\x05found\x18\x04 \x01(\bR\x05found\"k\n" + "\x15RequestJWTAuthRequest\x12\x17\n" + - "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + "\x05_hint\"\x9a\x02\n" + "\x16RequestJWTAuthResponse\x12(\n" + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + @@ -7525,9 +7548,10 @@ const file_daemon_proto_rawDesc = "" + "\x14WaitJWTTokenResponse\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" + "\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" + - "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"C\n" + + "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"u\n" + "\x1fRequestExtendAuthSessionRequest\x12\x17\n" + - "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + "\x05_hint\"\xe0\x01\n" + " RequestExtendAuthSessionResponse\x12(\n" + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 18ce0e79c..a3e3f4500 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -894,6 +894,10 @@ message GetPeerSSHHostKeyResponse { message RequestJWTAuthRequest { // hint for OIDC login_hint parameter (typically email address) optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; } // RequestJWTAuthResponse contains authentication flow information @@ -937,6 +941,10 @@ message RequestExtendAuthSessionRequest { // Optional OIDC login_hint (typically the user's email) to pre-fill the // IdP login form. optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; } // RequestExtendAuthSessionResponse carries the verification URI the UI diff --git a/client/server/server.go b/client/server/server.go index 01778b8e0..f33e19075 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1723,8 +1723,8 @@ func (s *Server) RequestJWTAuth( hint = profilemanager.GetLoginHint() } - isDesktop := isUnixRunningDesktop() - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint) + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) if err != nil { return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) } @@ -1827,8 +1827,8 @@ func (s *Server) RequestExtendAuthSession( hint = profilemanager.GetLoginHint() } - isDesktop := isUnixRunningDesktop() - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint) + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) if err != nil { return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) } @@ -2000,13 +2000,6 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon return nil } -func isUnixRunningDesktop() bool { - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - return false - } - return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" -} - func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) { if s.connectClient == nil { return diff --git a/client/ssh/common.go b/client/ssh/common.go index 92e647b7d..3f4f3e9d1 100644 --- a/client/ssh/common.go +++ b/client/ssh/common.go @@ -13,6 +13,7 @@ import ( "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" ) const ( @@ -92,7 +93,8 @@ func printAuthInstructions(stderr io.Writer, authResponse *proto.RequestJWTAuthR // RequestJWTToken requests or retrieves a JWT token for SSH authentication func RequestJWTToken(ctx context.Context, client proto.DaemonServiceClient, stdout, stderr io.Writer, useCache bool, hint string, openBrowser func(string) error) (string, error) { - req := &proto.RequestJWTAuthRequest{} + // the ssh client runs in the user's session, the daemon does not: tell it what we can see + req := &proto.RequestJWTAuthRequest{HasGraphicalSession: util.HasGraphicalSession()} if hint != "" { req.Hint = &hint } @@ -193,4 +195,3 @@ func buildAddressList(hostname string, remote net.Addr) []string { } return addresses } - diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go index 28efe7cfd..d94cef696 100644 --- a/client/ui/authsession/service.go +++ b/client/ui/authsession/service.go @@ -58,7 +58,8 @@ func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (Exten return ExtendStartResult{}, err } - req := &proto.RequestExtendAuthSessionRequest{} + // a request from the UI implies a graphical session, which the daemon cannot detect itself + req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true} if p.Hint != "" { h := p.Hint req.Hint = &h diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index 1069f8754..aa649bb6d 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -108,10 +108,11 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err } req := &proto.LoginRequest{ - ManagementUrl: p.ManagementURL, - SetupKey: p.SetupKey, - Hostname: p.Hostname, - IsUnixDesktopClient: runtime.GOOS == "linux", + ManagementUrl: p.ManagementURL, + SetupKey: p.SetupKey, + Hostname: p.Hostname, + // a login driven by the UI always has a graphical session available + IsUnixDesktopClient: true, } if profileName != "" { req.ProfileName = ptrStr(profileName) diff --git a/util/common.go b/util/common.go index 89903b609..c08be3617 100644 --- a/util/common.go +++ b/util/common.go @@ -3,18 +3,69 @@ package util import ( "os" "os/exec" + "runtime" + "slices" "github.com/skratchdot/open-golang/open" ) +const ( + // envBrowser overrides the browser OpenBrowser launches + envBrowser = "BROWSER" + // envDesktopSession and envXDGCurrentDesktop are what xdg-open uses to pick a handler + envDesktopSession = "DESKTOP_SESSION" + envXDGCurrentDesktop = "XDG_CURRENT_DESKTOP" + // envDisplay and envWaylandDisplay are what a graphical browser needs to reach a display + envDisplay = "DISPLAY" + envWaylandDisplay = "WAYLAND_DISPLAY" + // envXDGSessionType names the session kind, e.g. tty, x11 or wayland + envXDGSessionType = "XDG_SESSION_TYPE" +) + // OpenBrowser opens the URL in a browser, respecting the BROWSER environment variable. func OpenBrowser(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { + if browser := os.Getenv(envBrowser); browser != "" { return exec.Command(browser, url).Start() } return open.Run(url) } +// browserSessionEnvVars returns the variables that decide whether OpenBrowser can open a URL. +// DISPLAY and WAYLAND_DISPLAY are exactly what xdg-open's own has_display() checks, and without +// them it degrades to terminal browsers. BROWSER is the explicit override both xdg-open and +// OpenBrowser honor first. DESKTOP_SESSION and XDG_CURRENT_DESKTOP only tell xdg-open which +// desktop-specific opener to prefer, so they are weaker evidence, kept because the previous +// detection relied on them alone and dropping them would demote sessions that work today. +func browserSessionEnvVars() []string { + return []string{envDisplay, envWaylandDisplay, envBrowser, envDesktopSession, envXDGCurrentDesktop} +} + +// graphicalXDGSessionTypes are the systemd-logind session types that come with a display. The +// other documented values are "tty" and "unspecified"; anything unrecognized is treated as no +// display, so an unknown value picks the device code flow, which works without a browser. +func graphicalXDGSessionTypes() []string { + return []string{"x11", "wayland", "mir"} +} + +// HasGraphicalSession reports whether this process can open a browser and serve a loopback +// redirect back to it. Windows and macOS always can. On Linux and FreeBSD the answer is env +// based, so it only holds for a process started from the graphical session itself: a service +// does not inherit those variables and always reports false, which is why callers running in +// the user's session pass their own answer to the daemon. +func HasGraphicalSession() bool { + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + return true + } + + for _, env := range browserSessionEnvVars() { + if os.Getenv(env) != "" { + return true + } + } + + return slices.Contains(graphicalXDGSessionTypes(), os.Getenv(envXDGSessionType)) +} + // SliceDiff returns the elements in slice `x` that are not in slice `y` func SliceDiff(x, y []string) []string { mapY := make(map[string]struct{}, len(y)) diff --git a/util/session_test.go b/util/session_test.go new file mode 100644 index 000000000..f301ff8f7 --- /dev/null +++ b/util/session_test.go @@ -0,0 +1,50 @@ +package util + +import ( + "os" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHasGraphicalSession(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + assert.True(t, HasGraphicalSession(), "%s always has a graphical session", runtime.GOOS) + return + } + + // clear anything inherited from the session running the test, restored on cleanup + for _, env := range append(browserSessionEnvVars(), envXDGSessionType) { + t.Setenv(env, "") + os.Unsetenv(env) + } + + assert.False(t, HasGraphicalSession(), "no session variables means no graphical session") + + tests := []struct { + env string + value string + expected bool + }{ + {env: envDisplay, value: ":0", expected: true}, + {env: envWaylandDisplay, value: "wayland-0", expected: true}, + {env: envDesktopSession, value: "gnome", expected: true}, + {env: envXDGCurrentDesktop, value: "KDE", expected: true}, + {env: envBrowser, value: "firefox", expected: true}, + {env: envXDGSessionType, value: "wayland", expected: true}, + {env: envXDGSessionType, value: "x11", expected: true}, + {env: envXDGSessionType, value: "mir", expected: true}, + {env: envXDGSessionType, value: "tty", expected: false}, + {env: envXDGSessionType, value: "unspecified", expected: false}, + // an unrecognized type must not be read as a display: the device code flow works anyway + {env: envXDGSessionType, value: "something-new", expected: false}, + } + + for _, tt := range tests { + t.Run(tt.env+"="+tt.value, func(t *testing.T) { + t.Setenv(tt.env, tt.value) + assert.Equal(t, tt.expected, HasGraphicalSession(), "%s=%s", tt.env, tt.value) + }) + } +} From 1d372bb6348f2e7073c9a3657b15bc6c3b895af9 Mon Sep 17 00:00:00 2001 From: Kim Harre <99537307+znel2002@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:58:03 +0200 Subject: [PATCH 02/36] [infrastructure] Support non-interactive installation in getting-started.sh (#7168) --- infrastructure_files/getting-started.sh | 99 ++++++++++++++++++++----- 1 file changed, 80 insertions(+), 19 deletions(-) diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 4f2c1d82e..0fc5b23c5 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -111,6 +111,59 @@ check_nb_domain() { return 0 } +# Non-interactive configuration +# ------------------------------ +# Every prompt below can be pre-answered with an environment variable, so the +# script runs unattended (cloud-init, CI, Terraform, curl | bash). resolve() +# is the single place that decides env var vs prompt vs default; the read_* +# helpers stay pure prompts. +# +# Supported env vars: +# NETBIRD_DOMAIN domain/FQDN (required) +# NETBIRD_LETSENCRYPT_EMAIL ACME email (required for built-in Traefik) +# NETBIRD_AGENT_NETWORK true enables the agent-network preset +# NETBIRD_REVERSE_PROXY_TYPE 0-5 (default 0 = built-in Traefik) +# NETBIRD_ENABLE_PROXY true/false (default false) +# NETBIRD_ENABLE_CROWDSEC true/false (default false) +# NETBIRD_TRAEFIK_EXTERNAL_NETWORK external-Traefik network (type 1) +# NETBIRD_TRAEFIK_ENTRYPOINT external-Traefik entrypoint (type 1, default websecure) +# NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1) +# NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5) +# NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4) +# NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY + +# tty_available succeeds only when we may prompt: never when the operator has +# set NETBIRD_NON_INTERACTIVE=true, otherwise only when /dev/tty can actually +# be opened. A PTY can be attached in automation (CI runners, some +# provisioners), so the env override is the authoritative signal and the +# /dev/tty probe is the fallback. /dev/tty is a world-rw device node even with +# no terminal, so a permission test ([ -r ]) is not enough - we must open it. +tty_available() { + [[ "${NETBIRD_NON_INTERACTIVE:-}" == "true" ]] && return 1 + { true < /dev/tty; } 2>/dev/null +} + +# resolve ENV_VAR_NAME DEFAULT PROMPT_FN [prompt args...] +# env var set and non-empty -> its value +# interactive -> PROMPT_FN "$@" (prompt behavior unchanged) +# otherwise -> DEFAULT, or abort when DEFAULT is "required" +resolve() { + local env_name="$1" default="$2" prompt_fn="$3" + shift 3 + local env_value="${!env_name:-}" + if [[ -n "$env_value" ]]; then + echo "$env_value" + elif tty_available; then + "$prompt_fn" "$@" + elif [[ "$default" == "required" ]]; then + echo "$env_name is required for a non-interactive install." > /dev/stderr + exit 1 + else + echo "$default" + fi + return 0 +} + read_nb_domain() { READ_NETBIRD_DOMAIN="" echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr @@ -383,7 +436,14 @@ initialize_default_values() { } configure_domain() { + # Domain is validated (not a free-form value), so it keeps its own guard + # rather than going through resolve(): a valid NETBIRD_DOMAIN is used as-is, + # otherwise we prompt, or abort when there is no terminal to prompt on. if ! check_nb_domain "$NETBIRD_DOMAIN"; then + if ! tty_available; then + echo "NETBIRD_DOMAIN is required for a non-interactive install." > /dev/stderr + exit 1 + fi NETBIRD_DOMAIN=$(read_nb_domain) fi @@ -411,11 +471,7 @@ apply_agent_network_preset() { ENABLE_PROXY="true" ENABLE_CROWDSEC="false" - if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL}" ]]; then - TRAEFIK_ACME_EMAIL="${NETBIRD_LETSENCRYPT_EMAIL}" - else - TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email) - fi + TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) echo "" > /dev/stderr echo "Agent-network preset enabled (NETBIRD_AGENT_NETWORK=true):" > /dev/stderr @@ -437,35 +493,35 @@ configure_reverse_proxy() { return 0 fi - # Prompt for reverse proxy type - REVERSE_PROXY_TYPE=$(read_reverse_proxy_type) + # Reverse proxy type (env NETBIRD_REVERSE_PROXY_TYPE, else prompt, else 0) + REVERSE_PROXY_TYPE=$(resolve NETBIRD_REVERSE_PROXY_TYPE 0 read_reverse_proxy_type) # Handle built-in Traefik prompts (option 0) if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then - TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email) - ENABLE_PROXY=$(read_enable_proxy) + TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) + ENABLE_PROXY=$(resolve NETBIRD_ENABLE_PROXY false read_enable_proxy) if [[ "$ENABLE_PROXY" == "true" ]]; then - ENABLE_CROWDSEC=$(read_enable_crowdsec) + ENABLE_CROWDSEC=$(resolve NETBIRD_ENABLE_CROWDSEC false read_enable_crowdsec) fi fi # Handle external Traefik-specific prompts (option 1) if [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then - TRAEFIK_EXTERNAL_NETWORK=$(read_traefik_network) - TRAEFIK_ENTRYPOINT=$(read_traefik_entrypoint) - TRAEFIK_CERTRESOLVER=$(read_traefik_certresolver) + TRAEFIK_EXTERNAL_NETWORK=$(resolve NETBIRD_TRAEFIK_EXTERNAL_NETWORK "" read_traefik_network) + TRAEFIK_ENTRYPOINT=$(resolve NETBIRD_TRAEFIK_ENTRYPOINT websecure read_traefik_entrypoint) + TRAEFIK_CERTRESOLVER=$(resolve NETBIRD_TRAEFIK_CERTRESOLVER "" read_traefik_certresolver) fi # Handle port binding for external proxy options (2-5) if [[ "$REVERSE_PROXY_TYPE" -ge 2 ]]; then - BIND_LOCALHOST_ONLY=$(read_port_binding_preference) + BIND_LOCALHOST_ONLY=$(resolve NETBIRD_BIND_LOCALHOST_ONLY true read_port_binding_preference) fi # Handle Docker network prompts for external proxies (options 2-4) case "$REVERSE_PROXY_TYPE" in - 2) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx") ;; - 3) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx Proxy Manager") ;; - 4) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Caddy") ;; + 2) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Nginx") ;; + 3) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Nginx Proxy Manager") ;; + 4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;; *) ;; # No network prompt for other options esac return 0 @@ -643,8 +699,13 @@ start_services_and_show_instructions() { print_post_setup_instructions echo "" - echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... " - read -r < /dev/tty + if tty_available; then + echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... " + read -r < /dev/tty + else + echo "Non-interactive mode: starting NetBird containers now. Finish configuring" + echo "your reverse proxy using the instructions above so it can reach them." + fi echo -e "$MSG_STARTING_SERVICES" $DOCKER_COMPOSE_COMMAND up -d From 5544761b4780626b092af715cc7572baf30e8f9c Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:07:37 +0900 Subject: [PATCH 03/36] [client] Add Windows DNS configuration to the debug bundle (#7196) --- client/anonymize/anonymize.go | 12 +- client/anonymize/reverse_zone.go | 174 ++++++++ client/anonymize/reverse_zone_test.go | 171 ++++++++ client/internal/debug/debug.go | 8 + client/internal/debug/debug_nonunix.go | 2 +- client/internal/debug/debug_windows.go | 443 ++++++++++++++++++++ client/internal/debug/debug_windows_test.go | 146 +++++++ client/internal/debug/nrpt_windows.go | 317 ++++++++++++++ client/internal/dns/host_windows.go | 32 +- go.mod | 2 +- 10 files changed, 1296 insertions(+), 11 deletions(-) create mode 100644 client/anonymize/reverse_zone.go create mode 100644 client/anonymize/reverse_zone_test.go create mode 100644 client/internal/debug/debug_windows.go create mode 100644 client/internal/debug/debug_windows_test.go create mode 100644 client/internal/debug/nrpt_windows.go diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index acadb717b..c5d43ed55 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -305,6 +305,12 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string { return domain } + // A reverse zone names an address prefix, so it follows the address rules, + // which also keeps its digit labels intact. + if zone, ok := a.anonymizeReverseZone(baseDomain); ok { + return withTrailingDot(zone, hasDot) + } + if suffix := protectedSuffix(baseDomain); suffix != "" { if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain { return domain @@ -405,6 +411,10 @@ func (a *Anonymizer) AnonymizeString(str string) string { ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`) ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`) + // Reverse zones go first and are then held out of the passes below: their + // labels are digits, which the address patterns would otherwise consume. + str, restoreZones := a.replaceReverseZones(str) + str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) @@ -425,7 +435,7 @@ func (a *Anonymizer) AnonymizeString(str string) string { str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey) } - return str + return restoreZones(str) } // sortedDomains returns the domain mappings longest-first, so a full-FQDN diff --git a/client/anonymize/reverse_zone.go b/client/anonymize/reverse_zone.go new file mode 100644 index 000000000..b521b71b7 --- /dev/null +++ b/client/anonymize/reverse_zone.go @@ -0,0 +1,174 @@ +package anonymize + +import ( + "encoding/hex" + "net/netip" + "regexp" + "strconv" + "strings" +) + +const ( + reverseZoneSuffixV4 = ".in-addr.arpa" + reverseZoneSuffixV6 = ".ip6.arpa" + + v6Nibbles = 32 + v4Octets = 4 +) + +// reverseZoneRegexes match a reverse zone or a full reverse name in free text. +// They are applied before the address passes of AnonymizeString, whose IPv4 +// pattern would otherwise consume the digit labels of a zone and replace parts +// of it with unrelated addresses. +var reverseZoneRegexes = []*regexp.Regexp{ + regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`), + regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`), +} + +// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form +// of the prefix it encodes, so it follows the address rules rather than the +// domain ones: the zone of an address that is preserved is preserved too, and +// the zone of one that is replaced names the replacement. This keeps a reverse +// zone recognizable as such, and consistent with the addresses it belongs to +// elsewhere in the same output. It reports false for anything that is not a +// reverse zone. +func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) { + prefix, labelCount, suffix, ok := parseReverseZone(domain) + if !ok { + return "", false + } + + anonymized := a.AnonymizeIP(prefix) + if anonymized == prefix { + return domain, true + } + + return reverseZoneName(anonymized, labelCount) + suffix, true +} + +// replaceReverseZones anonymizes every reverse zone in str and swaps each one +// for a placeholder, returning a function that puts the anonymized zones back. +// The placeholders carry no dots, digits or colons, so no later pass matches +// them. +func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) { + var zones []string + + for _, re := range reverseZoneRegexes { + str = re.ReplaceAllStringFunc(str, func(match string) string { + zone, ok := a.anonymizeReverseZone(match) + if !ok { + return match + } + + zones = append(zones, zone) + return reverseZonePlaceholder(len(zones) - 1) + }) + } + + if len(zones) == 0 { + return str, func(s string) string { return s } + } + + return str, func(s string) string { + for i, zone := range zones { + s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone) + } + return s + } +} + +func reverseZonePlaceholder(index int) string { + return "\x00reversezone" + strconv.Itoa(index) + "\x00" +} + +// parseReverseZone turns a reverse zone into the address of the prefix its +// labels spell backwards, padding the absent low-order part with zeroes, and +// returns the label count and zone suffix so the name can be rebuilt. +func parseReverseZone(domain string) (netip.Addr, int, string, bool) { + lower := strings.ToLower(domain) + + switch { + case strings.HasSuffix(lower, reverseZoneSuffixV4): + labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".") + addr, ok := reverseZoneAddrV4(labels) + return addr, len(labels), reverseZoneSuffixV4, ok + case strings.HasSuffix(lower, reverseZoneSuffixV6): + labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".") + addr, ok := reverseZoneAddrV6(labels) + return addr, len(labels), reverseZoneSuffixV6, ok + default: + return netip.Addr{}, 0, "", false + } +} + +func reverseZoneAddrV4(labels []string) (netip.Addr, bool) { + if len(labels) == 0 || len(labels) > v4Octets { + return netip.Addr{}, false + } + + var octets [v4Octets]byte + for i, label := range labels { + octet, err := strconv.ParseUint(label, 10, 8) + if err != nil { + return netip.Addr{}, false + } + octets[len(labels)-1-i] = byte(octet) + } + + return netip.AddrFrom4(octets), true +} + +func reverseZoneAddrV6(labels []string) (netip.Addr, bool) { + if len(labels) == 0 || len(labels) > v6Nibbles { + return netip.Addr{}, false + } + + nibbles := make([]byte, 0, v6Nibbles) + for i := len(labels) - 1; i >= 0; i-- { + if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) { + return netip.Addr{}, false + } + nibbles = append(nibbles, labels[i][0]) + } + for len(nibbles) < v6Nibbles { + nibbles = append(nibbles, '0') + } + + var groups []string + for i := 0; i < len(nibbles); i += 4 { + groups = append(groups, string(nibbles[i:i+4])) + } + + addr, err := netip.ParseAddr(strings.Join(groups, ":")) + if err != nil { + return netip.Addr{}, false + } + + return addr, true +} + +// reverseZoneName spells the first labelCount labels of addr backwards, the +// inverse of parseReverseZone, without the zone suffix. +func reverseZoneName(addr netip.Addr, labelCount int) string { + labels := make([]string, 0, labelCount) + + if addr.Is4() { + octets := addr.As4() + for i := labelCount - 1; i >= 0; i-- { + labels = append(labels, strconv.Itoa(int(octets[i]))) + } + return strings.Join(labels, ".") + } + + address := addr.As16() + nibbles := hex.EncodeToString(address[:]) + for i := labelCount - 1; i >= 0; i-- { + labels = append(labels, string(nibbles[i])) + } + + return strings.Join(labels, ".") +} + +func isHexDigit(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} diff --git a/client/anonymize/reverse_zone_test.go b/client/anonymize/reverse_zone_test.go new file mode 100644 index 000000000..8c3b8954a --- /dev/null +++ b/client/anonymize/reverse_zone_test.go @@ -0,0 +1,171 @@ +package anonymize + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newLeveledAnonymizer(level Level) *Anonymizer { + a := NewAnonymizer(DefaultAddresses()) + a.SetLevel(level) + return a +} + +// TestAnonymizeDomainReverseZone covers reverse zones going through the address +// rules instead of the domain ones, so a zone stays a zone and an address that +// is preserved keeps the zone that names it. +func TestAnonymizeDomainReverseZone(t *testing.T) { + // 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the + // default level and replaced from the internal pool at the strict one + const overlayZone = "64.100.in-addr.arpa" + + t.Run("overlay zone preserved at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address") + }) + + t.Run("private zone preserved at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address") + }) + + t.Run("overlay zone replaced at the strict level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelStrict) + + got := a.AnonymizeDomain(overlayZone) + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got) + assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix") + assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2, + "should keep the label count, got %q", got) + }) + + t.Run("public zone replaced at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeDomain("113.0.203.in-addr.arpa") + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got) + assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix") + }) + + t.Run("zone of an address keeps that address mapping", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + anonymizedAddr := a.AnonymizeIPString("203.0.113.7") + got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa") + + octets := strings.Split(anonymizedAddr, ".") + want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4 + assert.Equal(t, want, got, "should name the same replacement as the address itself") + }) + + t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6 + got := a.AnonymizeDomain(zone) + + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got) + labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".") + assert.Len(t, labels, 28, "should keep every nibble label, got %q", got) + for _, label := range labels { + assert.Len(t, label, 1, "nibble label %q should stay a single digit", label) + } + }) + + t.Run("trailing dot is kept", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot") + }) + + t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeDomain("not-a-zone.in-addr.arpa") + assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization") + }) +} + +// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as +// a DNS log line, is not chewed up by the address passes. The IPv4 pattern +// matches any run of dotted digits, which a reverse zone is made of. +func TestAnonymizeStringReverseZone(t *testing.T) { + t.Run("ipv6 zone survives the address passes", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6 + got := a.AnonymizeString("question: domain=" + zone + " type=PTR") + + assert.Contains(t, got, "type=PTR", "should keep the rest of the line") + assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address") + + labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".") + assert.Len(t, labels, 28, "should keep every nibble label, got %q", got) + }) + + t.Run("preserved ipv4 zone is untouched", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + line := "reverse zone 64.100.in-addr.arpa registered" + assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address") + }) + + t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7") + assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone") + assert.NotContains(t, got, "203.0.113.7", "should replace the address") + assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix") + }) +} + +func TestParseReverseZone(t *testing.T) { + tests := []struct { + name string + zone string + addr string + labels int + }{ + {name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2}, + {name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3}, + {name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4}, + { + name: "v6 prefix", + zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6, + addr: "2::", + labels: 28, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + addr, labels, suffix, ok := parseReverseZone(tc.zone) + require.True(t, ok, "should decode the reverse zone") + assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix") + assert.Equal(t, tc.labels, labels, "should count the labels") + assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone") + }) + } +} + +func TestParseReverseZoneRejectsNonZones(t *testing.T) { + tests := []string{ + "example.com", + "in-addr.arpa", + "x.100" + reverseZoneSuffixV4, + "256" + reverseZoneSuffixV4, + "1.2.3.4.5" + reverseZoneSuffixV4, + "ab" + reverseZoneSuffixV6, + "g" + reverseZoneSuffixV6, + } + + for _, zone := range tests { + t.Run(zone, func(t *testing.T) { + _, _, _, ok := parseReverseZone(zone) + assert.False(t, ok, "should reject %q", zone) + }) + } +} diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index dbe22139a..1d31c75ca 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -51,6 +51,7 @@ nftables.txt: Anonymized nftables rules with packet counters across all families sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only). resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided. scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided. +dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided. resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder. config.txt: Anonymized configuration information of the NetBird client. network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules. @@ -237,6 +238,13 @@ scutil_dns.txt (macOS only): - Shows DNS configuration for all network interfaces - Includes search domains, nameservers, and DNS resolver settings - All IP addresses and domain names are anonymized + +dns_windows.txt (Windows only): +- Lists the NRPT rules of both policy stores, the local one and the group policy one, marking the rules the client created +- Follows them with the policy table the resolver has loaded, which differs from the rules while a change has not been picked up yet +- Includes the DNS client group policy, the global TCP/IP and Dnscache parameters, and the DNS values of every interface that has any +- Ends with the resolver configuration in effect per adapter, from GetAdaptersAddresses +- All IP addresses and domain names are anonymized ` const ( diff --git a/client/internal/debug/debug_nonunix.go b/client/internal/debug/debug_nonunix.go index 18d017050..adc9b9649 100644 --- a/client/internal/debug/debug_nonunix.go +++ b/client/internal/debug/debug_nonunix.go @@ -1,4 +1,4 @@ -//go:build !unix +//go:build !unix && !windows package debug diff --git a/client/internal/debug/debug_windows.go b/client/internal/debug/debug_windows.go new file mode 100644 index 000000000..e88940fd3 --- /dev/null +++ b/client/internal/debug/debug_windows.go @@ -0,0 +1,443 @@ +//go:build windows + +package debug + +import ( + "encoding/hex" + "errors" + "fmt" + "net/netip" + "strings" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + nbdns "github.com/netbirdio/netbird/client/internal/dns" +) + +const dnsInfoFileName = "dns_windows.txt" + +const ( + gpoDNSClientRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient` + tcpipParamsPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters` + dnscacheParams = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters` +) + +// interfaceDNSValues are the per-interface values that decide how a name is +// resolved and registered. Everything the DNS host manager writes is in here, +// so a bundle shows both what we set and what it replaced. +var interfaceDNSValues = []string{ + "NameServer", + "DhcpNameServer", + "Domain", + "DhcpDomain", + "SearchList", + "RegistrationEnabled", + "DisableDynamicUpdate", + "MaxNumberOfAddressesToRegister", + "EnableDHCP", +} + +// addDNSInfo collects and adds DNS configuration information to the archive +func (g *BundleGenerator) addDNSInfo() error { + if err := g.addFileToZip(strings.NewReader(g.collectDNSInfo()), dnsInfoFileName); err != nil { + return fmt.Errorf("add DNS info to zip: %w", err) + } + + return nil +} + +// collectDNSInfo renders the report. Everything below it reaches the platform +// through COM and through lazily resolved procedures, which panic when a +// procedure is missing rather than returning an error, and a debug bundle is not +// allowed to take the daemon down. The panic is contained here, and whatever was +// collected before it is kept and reported with it. +func (g *BundleGenerator) collectDNSInfo() (content string) { + var sb strings.Builder + + defer func() { + if r := recover(); r != nil { + log.Errorf("collecting Windows DNS configuration panicked: %v", r) + fmt.Fprintf(&sb, "\nerror: collection stopped: %v\n", r) + } + content = sb.String() + }() + + sb.WriteString("Windows DNS configuration\n") + sb.WriteString("=========================\n") + + adapters, adaptersErr := adapterAddresses() + + g.writeNRPTRules(&sb, "NRPT rules, local policy store", nbdns.DNSPolicyConfigRoot) + g.writeNRPTRules(&sb, "NRPT rules, group policy store", nbdns.GPODNSPolicyConfigRoot) + g.writeEffectiveNRPTPolicies(&sb) + g.writeRegistryKey(&sb, "DNS client group policy", gpoDNSClientRoot) + g.writeRegistryKey(&sb, "Global TCP/IP parameters", tcpipParamsPath) + g.writeRegistryKey(&sb, "Dnscache parameters", dnscacheParams) + g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv4", nbdns.InterfaceConfigPath, adapterNames(adapters)) + g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv6", nbdns.InterfaceConfigPathV6, adapterNames(adapters)) + g.writeAdapterDNS(&sb, adapters, adaptersErr) + + return sb.String() +} + +// writeNRPTRules lists every rule in a policy store, ours and any other +// product's, since a foreign rule for the same namespace decides resolution +// just as ours does. Rules the client wrote are marked. +func (g *BundleGenerator) writeNRPTRules(sb *strings.Builder, title, root string) { + writeSection(sb, title, root) + + names, err := subKeyNames(root) + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + if len(names) == 0 { + sb.WriteString("no rules\n") + return + } + + for _, name := range names { + owner := "" + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(nbdns.NRPTKeyPrefix)) { + owner = " (netbird)" + } + fmt.Fprintf(sb, "%s%s\n", name, owner) + g.writeValues(sb, root+`\`+name, nil, " ") + } +} + +// writeEffectiveNRPTPolicies reports the table the resolver answers from, which +// the registry cannot show: a rule is written before it is loaded, and it keeps +// being enforced after its key is gone until the resolver reloads its policy. +func (g *BundleGenerator) writeEffectiveNRPTPolicies(sb *strings.Builder) { + writeSection(sb, "NRPT policy table in effect", nrptPolicyClass+"."+nrptPolicyMethod+" in "+nrptPolicyNamespace) + + entries, err := effectiveNRPTPolicies() + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + if len(entries) == 0 { + sb.WriteString("no policies\n") + return + } + + for _, entry := range entries { + fmt.Fprintf(sb, "%s\n", g.anonymizeValue("Namespace", entry.namespace)) + for _, value := range entry.values { + fmt.Fprintf(sb, " %s: %s\n", value.name, g.anonymizeValue(value.name, value.value)) + } + } +} + +// writeInterfaceDNS reports the DNS values of every interface that has any, so +// the netbird interface can be compared against the physical ones. The registry +// keys the values by GUID, so each is named from the adapter list; a GUID with +// no adapter is a leftover key of an interface that no longer exists. +func (g *BundleGenerator) writeInterfaceDNS(sb *strings.Builder, title, root string, names map[string]string) { + writeSection(sb, title, root) + + guids, err := subKeyNames(root) + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + var reported int + for _, guid := range guids { + var iface strings.Builder + g.writeValues(&iface, root+`\`+guid, interfaceDNSValues, " ") + if iface.Len() == 0 { + continue + } + + name, ok := names[strings.ToLower(guid)] + if !ok { + name = "no adapter with this GUID" + } + + reported++ + fmt.Fprintf(sb, "%s (%s)\n%s", guid, name, iface.String()) + } + + if reported == 0 { + sb.WriteString("no interface holds DNS values\n") + } +} + +// writeRegistryKey reports the values of a single key, without its subkeys. +func (g *BundleGenerator) writeRegistryKey(sb *strings.Builder, title, path string) { + writeSection(sb, title, path) + + var values strings.Builder + g.writeValues(&values, path, nil, "") + if values.Len() == 0 { + sb.WriteString("no values\n") + return + } + + sb.WriteString(values.String()) +} + +// writeValues renders the values of a key. A nil names list reports every +// value, otherwise only those named and present. +func (g *BundleGenerator) writeValues(sb *strings.Builder, path string, names []string, indent string) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, windows.ERROR_PATH_NOT_FOUND): + // an absent key is the normal state for the GPO store and for + // interfaces without DNS settings + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", path) + return + case err != nil: + fmt.Fprintf(sb, "%serror: open HKEY_LOCAL_MACHINE\\%s: %v\n", indent, path, err) + return + } + defer closeKey(k) + + if names == nil { + names, err = k.ReadValueNames(-1) + if err != nil { + fmt.Fprintf(sb, "%serror: read value names: %v\n", indent, err) + return + } + } + + for _, name := range names { + value, err := readRegistryValue(k, name) + switch { + case errors.Is(err, registry.ErrNotExist): + // the caller asks for a fixed set of values, most of which a + // given interface does not carry + continue + case err != nil: + // report rather than omit: a value that is there but cannot be + // read reads as unset otherwise + fmt.Fprintf(sb, "%s%s: error: %v\n", indent, name, err) + continue + } + + fmt.Fprintf(sb, "%s%s: %s\n", indent, name, g.anonymizeValue(name, value)) + } +} + +// anonymizeValue redacts a registry value according to what its name says it +// holds. Domains and addresses are handled per entry rather than by the string +// pass: the pass only replaces domains something else in the bundle already +// seeded, and its address regex would eat the digit labels of a reverse zone. +func (g *BundleGenerator) anonymizeValue(name, value string) string { + if !g.anonymize || value == "" { + return value + } + + switch { + case holdsDomains(name): + return joinValueEntries(splitValueEntries(value), g.anonymizeDomain) + case holdsAddresses(name): + return joinValueEntries(splitValueEntries(value), g.anonymizer.AnonymizeIPString) + default: + return g.anonymizer.AnonymizeString(value) + } +} + +// holdsDomains reports whether a value name holds domains: the domain list of +// an NRPT rule (Name) or of the policy table (Namespace), a search list, the +// DNS suffix values of the TCP/IP and policy keys, which all end in "Domain" +// (Domain, DhcpDomain, NV Domain, ICSDomain), and a proxy host name. +func holdsDomains(name string) bool { + lower := strings.ToLower(name) + return lower == "name" || lower == "namespace" || lower == "searchlist" || + strings.HasSuffix(lower, "domain") || strings.HasSuffix(lower, "proxyname") +} + +// holdsAddresses reports whether a value name holds DNS server addresses +// (NameServer, DhcpNameServer, GenericDNSServers, NameServers). +func holdsAddresses(name string) bool { + lower := strings.ToLower(name) + return strings.Contains(lower, "nameserver") || strings.Contains(lower, "dnsserver") +} + +// adapterNames maps adapter GUIDs, as the registry keys the interfaces, to the +// names an operator sees. +func adapterNames(adapters []*windows.IpAdapterAddresses) map[string]string { + names := make(map[string]string, len(adapters)) + for _, adapter := range adapters { + guid := windows.BytePtrToString(adapter.AdapterName) + names[strings.ToLower(guid)] = windows.UTF16PtrToString(adapter.FriendlyName) + } + return names +} + +// writeAdapterDNS reports the resolver configuration in effect per adapter, +// which is what the resolver uses for a name no NRPT rule matches. +func (g *BundleGenerator) writeAdapterDNS(sb *strings.Builder, adapters []*windows.IpAdapterAddresses, err error) { + writeSection(sb, "Adapter DNS configuration", "GetAdaptersAddresses") + + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + for _, adapter := range adapters { + name := windows.UTF16PtrToString(adapter.FriendlyName) + suffix := g.anonymizeDomain(windows.UTF16PtrToString(adapter.DnsSuffix)) + + fmt.Fprintf(sb, "%s (index %d, oper status %d)\n", name, adapter.IfIndex, adapter.OperStatus) + fmt.Fprintf(sb, " DNS suffix: %s\n", suffix) + + var servers []string + for server := adapter.FirstDnsServerAddress; server != nil; server = server.Next { + addr, ok := netip.AddrFromSlice(server.Address.IP()) + if !ok { + continue + } + + addr = addr.Unmap() + if g.anonymize { + addr = g.anonymizer.AnonymizeIP(addr) + } + servers = append(servers, addr.String()) + } + + fmt.Fprintf(sb, " DNS servers: %s\n", strings.Join(servers, ", ")) + } +} + +// anonymizeDomain anonymizes a single domain, keeping the leading dot an NRPT +// match domain carries. +func (g *BundleGenerator) anonymizeDomain(entry string) string { + if !g.anonymize { + return entry + } + + domain, dot := strings.CutPrefix(entry, ".") + if domain == "" { + return entry + } + + anonymized := g.anonymizer.AnonymizeDomain(domain) + if dot { + anonymized = "." + anonymized + } + return anonymized +} + +// splitValueEntries splits a registry value that holds a list. The separator +// differs per value: a REG_MULTI_SZ arrives joined with ", ", a SearchList is +// comma separated and a NameServer may use commas or spaces. +func splitValueEntries(value string) []string { + return strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' + }) +} + +func joinValueEntries(entries []string, anonymize func(string) string) string { + for i, entry := range entries { + entries[i] = anonymize(entry) + } + return strings.Join(entries, ", ") +} + +func writeSection(sb *strings.Builder, title, source string) { + fmt.Fprintf(sb, "\n%s\n%s\n%s\n", title, strings.Repeat("-", len(title)), source) +} + +func subKeyNames(root string) ([]string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS) + if err != nil { + return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err) + } + defer closeKey(k) + + names, err := k.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkey names: %w", err) + } + + return names, nil +} + +// readRegistryValue renders a value as text regardless of its type, so an +// unexpected type in a policy key still shows up instead of being dropped. +func readRegistryValue(k registry.Key, name string) (string, error) { + _, valueType, err := k.GetValue(name, nil) + if err != nil { + return "", fmt.Errorf("get value %s: %w", name, err) + } + + switch valueType { + case registry.SZ, registry.EXPAND_SZ: + value, _, err := k.GetStringValue(name) + if err != nil { + return "", fmt.Errorf("get string value %s: %w", name, err) + } + return value, nil + case registry.MULTI_SZ: + values, _, err := k.GetStringsValue(name) + if err != nil { + return "", fmt.Errorf("get strings value %s: %w", name, err) + } + return strings.Join(values, ", "), nil + case registry.DWORD, registry.QWORD: + value, _, err := k.GetIntegerValue(name) + if err != nil { + return "", fmt.Errorf("get integer value %s: %w", name, err) + } + return fmt.Sprintf("%d (0x%x)", value, value), nil + case registry.BINARY: + value, _, err := k.GetBinaryValue(name) + if err != nil { + return "", fmt.Errorf("get binary value %s: %w", name, err) + } + return hex.EncodeToString(value), nil + default: + return fmt.Sprintf("", valueType), nil + } +} + +// adapterAddresses returns the adapter list including DNS servers. The call +// reports the size it needs, so grow the buffer and retry until it fits. +func adapterAddresses() (adapters []*windows.IpAdapterAddresses, err error) { + // GetAdaptersAddresses is resolved on first use and panics when it is + // missing, so this reports it as an error and leaves the rest of the + // report intact. + defer func() { + if r := recover(); r != nil { + adapters, err = nil, fmt.Errorf("GetAdaptersAddresses: %v", r) + } + }() + + const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST + + size := uint32(15000) + for range 3 { + buf := make([]byte, size) + first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])) + + err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, flags, 0, first, &size) + if errors.Is(err, windows.ERROR_BUFFER_OVERFLOW) { + continue + } + if err != nil { + return nil, fmt.Errorf("GetAdaptersAddresses: %w", err) + } + + for adapter := first; adapter != nil; adapter = adapter.Next { + adapters = append(adapters, adapter) + } + return adapters, nil + } + + return nil, fmt.Errorf("GetAdaptersAddresses: buffer kept growing") +} + +func closeKey(k registry.Key) { + if err := k.Close(); err != nil { + log.Debugf("close registry key: %v", err) + } +} diff --git a/client/internal/debug/debug_windows_test.go b/client/internal/debug/debug_windows_test.go new file mode 100644 index 000000000..47df3f6f9 --- /dev/null +++ b/client/internal/debug/debug_windows_test.go @@ -0,0 +1,146 @@ +//go:build windows + +package debug + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/anonymize" +) + +func newDNSValueGenerator(level anonymize.Level) *BundleGenerator { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + return &BundleGenerator{ + anonymize: true, + anonymizeLevel: level, + anonymizer: anonymizer, + } +} + +// TestAnonymizeValueByName covers the value kinds of the DNS registry keys. The +// names decide the treatment, because the string pass alone replaces only +// domains another part of the bundle already seeded. +func TestAnonymizeValueByName(t *testing.T) { + tests := []struct { + name string + valueName string + value string + assert func(t *testing.T, got string) + }{ + { + name: "NRPT match domains keep the leading dot", + valueName: "Name", + value: ".internal.example.com, .corp.example.org", + assert: func(t *testing.T, got string) { + t.Helper() + for _, entry := range strings.Split(got, ", ") { + assert.True(t, strings.HasPrefix(entry, "."), "entry %q should keep its leading dot", entry) + assert.NotContains(t, entry, "example", "entry %q should not keep the original domain", entry) + } + }, + }, + { + name: "any value name ending in Domain is treated as a domain", + valueName: "ICSDomain", + value: "mshome.net", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "mshome", "should anonymize a domain suffix value") + }, + }, + { + name: "search list is a comma separated domain list", + valueName: "SearchList", + value: "corp.example.com,branch.example.com", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "example", "should anonymize every search domain") + assert.Len(t, strings.Split(got, ", "), 2, "should keep both search domains") + }, + }, + { + name: "name servers are anonymized as addresses", + valueName: "DhcpNameServer", + value: "203.0.113.10 8.8.8.8", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "203.0.113.10", "should anonymize a public resolver address") + // well-known resolvers stay readable at every level + assert.Contains(t, got, "8.8.8.8", "should keep a well-known resolver address") + }, + }, + { + name: "opaque values are left to the string pass", + valueName: "DataBasePath", + value: `%SystemRoot%\System32\drivers\etc`, + assert: func(t *testing.T, got string) { + t.Helper() + assert.Equal(t, `%SystemRoot%\System32\drivers\etc`, got, "should not alter a path") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := newDNSValueGenerator(anonymize.LevelDefault) + tc.assert(t, g.anonymizeValue(tc.valueName, tc.value)) + }) + } +} + +// TestParseNRPTPolicyTable parses the MOF text of the policy table out +// parameters, as the provider on a client with one NRPT rule renders it. +func TestParseNRPTPolicyTable(t *testing.T) { + const text = `[abstract] +class __PARAMETERS +{ + [Out, EmbeddedInstance("DnsClientPolicyConfiguration"): ToSubClass, ID(2): DisableOverride ToInstance] DnsClientPolicyConfiguration cmdletOutput[] = { +instance of DnsClientPolicyConfiguration +{ + DirectAccessProxyType = "NoProxy"; + DirectAccessQueryIPsecRequired = FALSE; + NameEncoding = "Utf8WithoutMapping"; + Namespace = ".0.100.in-addr.arpa"; +}, +instance of DnsClientPolicyConfiguration +{ + DirectAccessProxyType = "NoProxy"; + NameEncoding = "Utf8WithoutMapping"; + NameServers = {"100.0.255.254", "100.0.255.253"}; + Namespace = ".nb.internal"; +}}; + [in] boolean Effective; + [out] uint32 ReturnValue = 0; +}; +` + + entries := parseNRPTPolicyTable(text) + require.Len(t, entries, 2, "should parse both embedded instances") + + assert.Equal(t, ".0.100.in-addr.arpa", entries[0].namespace, "should read the namespace of the first instance") + assert.Equal(t, ".nb.internal", entries[1].namespace, "should read the namespace of the second instance") + + assert.Equal(t, []registryValue{ + {name: "DirectAccessProxyType", value: "NoProxy"}, + {name: "DirectAccessQueryIPsecRequired", value: "FALSE"}, + {name: "NameEncoding", value: "Utf8WithoutMapping"}, + }, entries[0].values, "should keep the remaining values in order") + + assert.Contains(t, entries[1].values, registryValue{name: "NameServers", value: "100.0.255.254, 100.0.255.253"}, + "should flatten a MOF array") + + for _, value := range entries[1].values { + assert.NotContains(t, value.name, "ReturnValue", "should not read the class level parameters as values") + } +} + +func TestParseNRPTPolicyTableEmpty(t *testing.T) { + assert.Empty(t, parseNRPTPolicyTable(""), "should parse no entries from empty text") + assert.Empty(t, parseNRPTPolicyTable("class __PARAMETERS\n{\n};\n"), "should parse no entries from a table with no instances") +} diff --git a/client/internal/debug/nrpt_windows.go b/client/internal/debug/nrpt_windows.go new file mode 100644 index 000000000..6b6e0e29a --- /dev/null +++ b/client/internal/debug/nrpt_windows.go @@ -0,0 +1,317 @@ +//go:build windows + +package debug + +import ( + "errors" + "fmt" + "runtime" + "strings" + "time" + + "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + log "github.com/sirupsen/logrus" +) + +const ( + // The NRPT policy table is reachable through the CIM class that backs + // Get-DnsClientNrptPolicy. Unlike the rules in the registry, the table is + // what the resolver currently has loaded, which is the only way to tell an + // applied rule from one that is merely written, in either direction. + nrptPolicyNamespace = `root\Microsoft\Windows\DNS` + nrptPolicyClass = "PS_DnsClientNrptPolicy" + nrptPolicyMethod = "Get" + + // The class has no instances, so the table comes from the out parameters + // of a static method call, rendered as MOF text: the embedded instances + // arrive as a safe array of objects, which cannot be read back through the + // COM bindings, and the text form carries all of them. + nrptPolicyInstanceKeyword = "instance of DnsClientPolicyConfiguration" + + nrptPolicyTimeout = 15 * time.Second +) + +// COM initialization results that leave the calling thread usable: S_FALSE for +// a thread this process already initialized, RPC_E_CHANGED_MODE for one that +// belongs to another apartment. +const ( + sFalse = 0x00000001 + rpcEChangedMode = 0x80010106 +) + +// nrptQueryInFlight admits one read of the policy table at a time. A provider +// that stops answering keeps its goroutine and the OS thread that goroutine +// pinned, so a later bundle reports that instead of pinning another one. +var nrptQueryInFlight = make(chan struct{}, 1) + +// nrptPolicyEntry is one namespace of the effective policy table, holding the +// values of an embedded DnsClientPolicyConfiguration instance in the order the +// provider reported them. +type nrptPolicyEntry struct { + namespace string + values []registryValue +} + +// registryValue is a name and its rendered value, shared by the registry and +// policy table readers so both anonymize by value name the same way. +type registryValue struct { + name string + value string +} + +// effectiveNRPTPolicies reads the effective NRPT table. The call is bounded +// because a WMI provider can block indefinitely and a debug bundle must not. +func effectiveNRPTPolicies() ([]nrptPolicyEntry, error) { + type result struct { + text string + err error + } + + select { + case nrptQueryInFlight <- struct{}{}: + default: + return nil, errors.New("an earlier read of the policy table has not returned") + } + + done := make(chan result, 1) + go func() { + // the slot is released here rather than by the caller, so a read that + // outlives the timeout holds it until the provider answers + defer func() { <-nrptQueryInFlight }() + + text, err := nrptPolicyTableText() + done <- result{text: text, err: err} + }() + + select { + case res := <-done: + if res.err != nil { + return nil, res.err + } + return parseNRPTPolicyTable(res.text), nil + case <-time.After(nrptPolicyTimeout): + return nil, errors.New("read of the policy table timed out") + } +} + +// nrptPolicyTableText calls the policy table method and returns the MOF text of +// its out parameters. +func nrptPolicyTableText() (text string, err error) { + // COM is per thread, and the collection is short lived, so the thread is + // pinned for the duration rather than initialized for the process. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + defer func() { + // The COM call chain is dynamically typed, so a provider that answers + // with an unexpected shape must not take the daemon down with it. + if r := recover(); r != nil { + err = fmt.Errorf("read NRPT policy table: %v", r) + } + }() + + owns, err := coInitialize() + if err != nil { + return "", err + } + if owns { + defer ole.CoUninitialize() + } + + locator, err := oleutil.CreateObject("WbemScripting.SWbemLocator") + if err != nil { + return "", fmt.Errorf("create WMI locator: %w", err) + } + defer locator.Release() + + dispatch, err := locator.QueryInterface(ole.IID_IDispatch) + if err != nil { + return "", fmt.Errorf("query WMI locator interface: %w", err) + } + defer dispatch.Release() + + service, err := dispatchCall(dispatch, "ConnectServer", nil, nrptPolicyNamespace) + if err != nil { + return "", fmt.Errorf("connect to %s: %w", nrptPolicyNamespace, err) + } + defer service.Release() + + inParams, err := spawnMethodInParams(service) + if err != nil { + return "", err + } + defer inParams.Release() + + // The effective table is the merge of the local and the group policy + // store, which is what the resolver answers from. + if _, err := oleutil.PutProperty(inParams, "Effective", true); err != nil { + return "", fmt.Errorf("set Effective parameter: %w", err) + } + + outParams, err := dispatchCall(service, "ExecMethod", nrptPolicyClass, nrptPolicyMethod, inParams) + if err != nil { + return "", fmt.Errorf("call %s.%s: %w", nrptPolicyClass, nrptPolicyMethod, err) + } + defer outParams.Release() + + textVariant, err := oleutil.CallMethod(outParams, "GetObjectText_") + if err != nil { + return "", fmt.Errorf("render policy table: %w", err) + } + defer func() { + if err := textVariant.Clear(); err != nil { + log.Debugf("clear policy table variant: %v", err) + } + }() + + return textVariant.ToString(), nil +} + +// spawnMethodInParams builds the in parameters instance the method needs. The +// provider rejects the call without one, even when every parameter is optional. +func spawnMethodInParams(service *ole.IDispatch) (*ole.IDispatch, error) { + class, err := dispatchCall(service, "Get", nrptPolicyClass) + if err != nil { + return nil, fmt.Errorf("get class %s: %w", nrptPolicyClass, err) + } + defer class.Release() + + methods, err := dispatchProperty(class, "Methods_") + if err != nil { + return nil, fmt.Errorf("get class methods: %w", err) + } + defer methods.Release() + + method, err := dispatchCall(methods, "Item", nrptPolicyMethod) + if err != nil { + return nil, fmt.Errorf("get method %s: %w", nrptPolicyMethod, err) + } + defer method.Release() + + params, err := dispatchProperty(method, "InParameters") + if err != nil { + return nil, fmt.Errorf("get method parameters: %w", err) + } + defer params.Release() + + inParams, err := dispatchCall(params, "SpawnInstance_") + if err != nil { + return nil, fmt.Errorf("spawn parameter instance: %w", err) + } + + return inParams, nil +} + +// parseNRPTPolicyTable pulls the embedded instances out of the MOF text. Each +// instance is a namespace of the table, with one name and value per line. +func parseNRPTPolicyTable(text string) []nrptPolicyEntry { + var entries []nrptPolicyEntry + var current *nrptPolicyEntry + + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line), ";")) + + switch { + case strings.HasPrefix(line, nrptPolicyInstanceKeyword): + entries = append(entries, nrptPolicyEntry{}) + current = &entries[len(entries)-1] + continue + case strings.HasPrefix(line, "}"): + // closes an instance, and the array with the last one, so the + // class level parameters that follow are not read as values + current = nil + continue + case current == nil, line == "{": + continue + } + + name, value, ok := strings.Cut(line, " = ") + if !ok { + continue + } + + value = unquoteMOFValue(value) + if name == "Namespace" { + current.namespace = value + continue + } + + current.values = append(current.values, registryValue{name: name, value: value}) + } + + return entries +} + +// unquoteMOFValue renders a MOF scalar or array as plain text: "a" becomes a, +// and {"a", "b"} becomes a, b. +func unquoteMOFValue(value string) string { + value = strings.TrimSpace(value) + + if inner, ok := strings.CutPrefix(value, "{"); ok { + value = strings.TrimSuffix(inner, "}") + + entries := strings.Split(value, ",") + for i, entry := range entries { + entries[i] = strings.Trim(strings.TrimSpace(entry), `"`) + } + return strings.Join(entries, ", ") + } + + return strings.Trim(value, `"`) +} + +// coInitialize prepares the calling thread for COM and reports whether this +// call owns the initialization, which decides whether it may be balanced with +// CoUninitialize. S_FALSE took a reference on a thread this process had already +// initialized and so has to be released, while RPC_E_CHANGED_MODE took none: +// the thread belongs to another apartment, which is usable but is not ours to +// uninitialize. +func coInitialize() (bool, error) { + err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED) + if err == nil { + return true, nil + } + + var oleErr *ole.OleError + if errors.As(err, &oleErr) { + switch oleErr.Code() { + case sFalse: + return true, nil + case rpcEChangedMode: + return false, nil + } + } + + return false, fmt.Errorf("initialize COM: %w", err) +} + +// dispatchCall calls a COM method that returns an object. +func dispatchCall(dispatch *ole.IDispatch, method string, params ...any) (*ole.IDispatch, error) { + variant, err := oleutil.CallMethod(dispatch, method, params...) + if err != nil { + return nil, err + } + + object := variant.ToIDispatch() + if object == nil { + return nil, fmt.Errorf("%s returned no object", method) + } + + return object, nil +} + +// dispatchProperty reads a COM property that holds an object. +func dispatchProperty(dispatch *ole.IDispatch, property string) (*ole.IDispatch, error) { + variant, err := oleutil.GetProperty(dispatch, property) + if err != nil { + return nil, err + } + + object := variant.ToIDispatch() + if object == nil { + return nil, fmt.Errorf("property %s holds no object", property) + } + + return object, nil +} diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 4f6ece532..d20fdd1d6 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -31,10 +31,28 @@ var ( dnsFlushResolverCacheFn = dnsapi.NewProc("DnsFlushResolverCache") ) +// Registry locations of the host DNS configuration this package programs, +// exported so a diagnostic reader reports the same locations that are written. const ( - dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match` - gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig` - gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\NetBird-Match` + // NRPTKeyPrefix starts the name of every NRPT rule key this client creates. + NRPTKeyPrefix = "NetBird-Match" + + // DNSPolicyConfigRoot holds the NRPT rules of the local policy store. + DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig` + + // GPODNSPolicyConfigRoot holds the NRPT rules of the group policy store, + // which takes precedence over the local one when it is present. + GPODNSPolicyConfigRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig` + + // InterfaceConfigPath and InterfaceConfigPathV6 hold the per-interface DNS + // settings, keyed by interface GUID, in separate hives per address family. + InterfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` + InterfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces` +) + +const ( + dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix dnsPolicyConfigVersionKey = "Version" dnsPolicyConfigVersionValue = 2 @@ -45,8 +63,6 @@ const ( nrptMaxDomainsPerRule = 50 - interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` - interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces` interfaceConfigNameServerKey = "NameServer" interfaceConfigDhcpNameSrvKey = "DhcpNameServer" interfaceConfigSearchListKey = "SearchList" @@ -84,7 +100,7 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { } var useGPO bool - k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE) + k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE) if err != nil { log.Debugf("failed to open GPO DNS policy root: %v", err) } else { @@ -123,7 +139,7 @@ func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error seen := make(map[netip.Addr]struct{}) var out []netip.Addr var merr *multierror.Error - for _, root := range []string{interfaceConfigPath, interfaceConfigPathV6} { + for _, root := range []string{InterfaceConfigPath, InterfaceConfigPathV6} { addrs, err := r.captureFromTcpipRoot(root) if err != nil { merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err)) @@ -496,7 +512,7 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st } func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { - regKeyPath := interfaceConfigPath + "\\" + r.guid + regKeyPath := InterfaceConfigPath + "\\" + r.guid regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE) if err != nil { return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) diff --git a/go.mod b/go.mod index f98073417..f119d4a92 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 github.com/go-jose/go-jose/v4 v4.1.4 + github.com/go-ole/go-ole v1.3.0 github.com/gobwas/ws v1.4.0 github.com/goccy/go-yaml v1.18.0 github.com/godbus/dbus/v5 v5.2.2 @@ -199,7 +200,6 @@ require ( github.com/go-ldap/ldap/v3 v3.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect From 85dd335836efb7d170f318222d96199d65529d56 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 14 Aug 2026 10:57:11 +0200 Subject: [PATCH 04/36] [client] Add CI check for translation key parity (#6852) English (en) is the source of truth for UI translation keys; the other nine locales rely on runtime English fallback for any missing key, so a gap never surfaces in CI. Add a dependency-free Node check that fails when any locale declared in _index.json does not carry the exact same key set as en (missing or orphaned keys), wired into a dedicated UI Translations workflow that runs on locale changes. Also close the one existing gap the check found: ja was missing daemon.outdated.download ("Download Latest"). Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ui-translations.yml | 42 +++++++++++ client/ui/frontend/package.json | 3 +- client/ui/i18n/check-translations.mjs | 104 ++++++++++++++++++++++++++ client/ui/i18n/locales/ja/common.json | 3 + 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ui-translations.yml create mode 100644 client/ui/i18n/check-translations.mjs diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml new file mode 100644 index 000000000..7d3b12f2d --- /dev/null +++ b/.github/workflows/ui-translations.yml @@ -0,0 +1,42 @@ +name: UI Translations + +on: + pull_request: + paths: + - "client/ui/i18n/locales/**" + - "client/ui/i18n/check-translations.mjs" + - ".github/workflows/ui-translations.yml" + push: + branches: + - main + paths: + - "client/ui/i18n/locales/**" + - "client/ui/i18n/check-translations.mjs" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-translations: + name: Check translation key parity + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + # English (en) is the source of truth for translation keys; every other + # locale declared in _index.json must carry the exact same key set. + - name: Check translation key parity + run: node client/ui/i18n/check-translations.mjs diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index 3131b36cd..dcef99ad3 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -15,7 +15,8 @@ "lint": "eslint \"src/**/*.{ts,tsx}\"", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", "check": "pnpm lint && pnpm typecheck && pnpm format:check", - "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck" + "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck", + "i18n:check": "node ../i18n/check-translations.mjs" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", diff --git a/client/ui/i18n/check-translations.mjs b/client/ui/i18n/check-translations.mjs new file mode 100644 index 000000000..bd076e0e0 --- /dev/null +++ b/client/ui/i18n/check-translations.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Validates that every shipped translation bundle carries exactly the same set +// of keys as the English source of truth. English (en) defines the keys; every +// other locale declared in _index.json must match it 1:1: +// +// - no missing keys — a missing key silently falls back to English at runtime +// (see i18n bundle fallback), so the gap never surfaces to users or CI +// without this check; +// - no orphaned keys — keys left behind after an English key is renamed or +// removed are dead weight and a sign the locale is drifting. +// +// Pure Node, no dependencies, so it runs without installing the frontend +// toolchain. +// +// Local: node client/ui/i18n/check-translations.mjs (or: pnpm i18n:check) +// CI: .github/workflows/ui-translations.yml + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE = "en"; +const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales"); +const isCI = Boolean(process.env.GITHUB_ACTIONS); + +function readJSON(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function keysOf(langCode) { + return Object.keys(readJSON(join(localesDir, langCode, "common.json"))); +} + +// Emit a GitHub Actions annotation so failures render inline on the PR diff. +function annotate(file, message) { + if (isCI) console.log(`::error file=${file}::${message}`); +} + +const index = readJSON(join(localesDir, "_index.json")); +const declared = index.languages.map((l) => l.code); + +if (!declared.includes(SOURCE)) { + console.error(`FATAL: source language "${SOURCE}" is not declared in _index.json`); + process.exit(1); +} + +const sourceKeys = keysOf(SOURCE); +const sourceSet = new Set(sourceKeys); +console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`); + +let failed = false; + +for (const code of declared) { + if (code === SOURCE) continue; + const file = `client/ui/i18n/locales/${code}/common.json`; + + let keys; + try { + keys = keysOf(code); + } catch (e) { + failed = true; + const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`; + console.error(`✗ ${code}: ${msg}`); + annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`); + continue; + } + + const set = new Set(keys); + const missing = sourceKeys.filter((k) => !set.has(k)); + const extra = keys.filter((k) => !sourceSet.has(k)); + + if (missing.length === 0 && extra.length === 0) { + console.log(`✓ ${code}: ${keys.length} keys`); + continue; + } + + failed = true; + console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`); + if (missing.length) { + console.error(` missing ${missing.length}: ${missing.join(", ")}`); + annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`); + } + if (extra.length) { + console.error(` extra ${extra.length}: ${extra.join(", ")}`); + annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + } +} + +// Locale directories present on disk but not declared in _index.json are never +// loaded by the app — surface them so dead translation files don't rot silently. +const onDisk = readdirSync(localesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); +const undeclared = onDisk.filter((d) => !declared.includes(d)); +if (undeclared.length) { + console.warn(`\n⚠ locale directories not declared in _index.json (not shipped): ${undeclared.join(", ")}`); +} + +console.log(); +if (failed) { + console.error("Translation check FAILED — every locale must match the English key set."); + process.exit(1); +} +console.log("Translation check passed — all locales match the English key set."); diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 10cf7598d..ec69de9a5 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1312,6 +1312,9 @@ "daemon.outdated.description": { "message": "このアプリを使用するには NetBird サービスを更新してください。" }, + "daemon.outdated.download": { + "message": "最新版をダウンロード" + }, "error.jwt_clock_skew": { "message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。" }, From 2cfe14d7ec6358f2f76e0f9888fcb2ed53a5c35d Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 14 Aug 2026 16:13:52 +0000 Subject: [PATCH 05/36] [client] Keep account email on Android logout, drop it on profile removal (#7200) Align Android logout semantics with the desktop UI and CLI: logging out no longer deletes the stored account email, so the next login passes it as the OIDC login_hint and the IdP preselects the account. Removing the profile is now the operation that deletes the email; previously RemoveProfile left the account file behind, which the fixed-name default profile would have inherited on recreation. --- client/android/login.go | 5 +++-- client/android/profile_manager.go | 24 ++++++++++++++++++------ client/android/profile_state.go | 8 ++++---- client/android/profile_state_test.go | 4 ++-- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/client/android/login.go b/client/android/login.go index 3f367b97f..897b1561e 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -204,8 +204,9 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } - // An empty hint is deliberate, not a fallback: a fresh or logged-out profile - // leaves the choice to the IdP, which is how accounts get switched. + // An empty hint is deliberate, not a fallback: a fresh profile leaves the + // choice to the IdP. Switching accounts is done by switching or removing + // profiles, not by logging out — logout keeps the email. if a.cfgPath != "" { if hint := readProfileEmail(a.cfgPath); hint != "" { if setter, ok := oAuthFlow.(loginHintSetter); ok { diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 3197124d7..20d585d6a 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -22,7 +22,8 @@ type Profile struct { ID string Name string // Email is the account this profile last logged in with, "" if it never - // completed an SSO login or was logged out. See profile_state.go. + // completed an SSO login. Kept across logouts; cleared when the profile is + // removed. See profile_state.go. Email string IsActive bool } @@ -200,11 +201,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error { return fmt.Errorf("failed to save config: %w", err) } - // Not fatal: a stale hint costs an account switch, not the logout itself. - if err := removeProfileEmail(configPath); err != nil { - log.Warnf("failed to clear stored account email for profile %s: %v", id, err) - } - + // The stored account email is kept on purpose, matching the desktop and CLI + // logout semantics: the next login passes it as the login_hint so the IdP + // preselects the account. Removing the profile is what deletes it. log.Infof("logged out from profile: %s", id) return nil } @@ -224,11 +223,24 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error { // RemoveProfile deletes a profile func (pm *ProfileManager) RemoveProfile(id string) error { + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + // Use ServiceManager (removes profile from profiles/ directory) if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil { return fmt.Errorf("failed to remove profile: %w", err) } + // The account file is this package's, not the ServiceManager's, so it must + // go here. The default profile has a fixed filename, so a recreated one + // would otherwise inherit the deleted profile's email as its login_hint. + // Not fatal: the profile itself is gone. + if err := removeProfileEmail(configPath); err != nil { + log.Warnf("failed to remove stored account email for profile %s: %v", id, err) + } + log.Infof("removed profile: %s", id) return nil } diff --git a/client/android/profile_state.go b/client/android/profile_state.go index 3f0a09701..0063b587f 100644 --- a/client/android/profile_state.go +++ b/client/android/profile_state.go @@ -90,10 +90,10 @@ func writeProfileEmail(configPath string, email string) error { return nil } -// removeProfileEmail drops the stored account email. Called on logout: while the -// email is on disk it goes out as a login_hint, which would steer the next login -// straight back into the account just logged out of. Mirrors the desktop UI's -// RemoveProfileState call. +// removeProfileEmail drops the stored account email. Called on profile removal, +// not on logout: a logged-out profile keeps its email so the next login passes +// it as the login_hint, matching the desktop and CLI semantics. Mirrors the +// desktop UI's RemoveProfileState call. func removeProfileEmail(configPath string) error { accountPath, err := profileAccountPathFor(configPath) if err != nil { diff --git a/client/android/profile_state_test.go b/client/android/profile_state_test.go index 623e16c3b..82a1c2a87 100644 --- a/client/android/profile_state_test.go +++ b/client/android/profile_state_test.go @@ -127,10 +127,10 @@ func TestWriteThenReadProfileEmail(t *testing.T) { t.Fatalf("remove: %v", err) } if got := readProfileEmail(configPath); got != "" { - t.Errorf("expected no email after logout, got %q", got) + t.Errorf("expected no email after removal, got %q", got) } - // Logout may run on a never-logged-in profile, so a second remove must pass. + // Removal may run on a never-logged-in profile, so a second remove must pass. if err := removeProfileEmail(configPath); err != nil { t.Fatalf("second remove should be a no-op: %v", err) } From ec6f1b8c277da3a67e198422e467a724c8cbe3ad Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:06:22 +0900 Subject: [PATCH 06/36] [client] Rank Windows route candidates by combined route and interface metric (#7210) --- .../systemops/routeselection_windows_test.go | 82 +++++++++++++++++++ .../systemops/systemops_windows.go | 27 ++++-- 2 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 client/internal/routemanager/systemops/routeselection_windows_test.go diff --git a/client/internal/routemanager/systemops/routeselection_windows_test.go b/client/internal/routemanager/systemops/routeselection_windows_test.go new file mode 100644 index 000000000..108338dd9 --- /dev/null +++ b/client/internal/routemanager/systemops/routeselection_windows_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package systemops + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSortRouteCandidates(t *testing.T) { + tests := []struct { + name string + candidates []candidateRoute + wantOrder []uint32 + }{ + { + name: "longest prefix wins over metrics", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 0, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 24, routeMetric: 100, interfaceMetric: 50}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // Windows ranks equal-length prefixes by route metric + interface metric, + // so a higher route metric on a low metric interface can still win. + name: "combined metric beats route metric alone", + candidates: []candidateRoute{ + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + {interfaceIndex: 5, prefixLength: 0, routeMetric: 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{5, 8}, + }, + { + name: "lower combined metric wins", + candidates: []candidateRoute{ + {interfaceIndex: 5, prefixLength: 0, routeMetric: 300, interfaceMetric: 5}, + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + }, + wantOrder: []uint32{8, 5}, + }, + { + name: "equal combined metric falls back to route metric", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 20, interfaceMetric: 10}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 25}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // The metrics are uint32 on the Windows side, so the sum must not wrap. + name: "combined metric beyond the uint32 range", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: math.MaxUint32, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: math.MaxUint32 - 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{2, 1}, + }, + { + name: "unknown interface metric ranks on route metric only", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 30, interfaceMetric: -1}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 10}, + }, + wantOrder: []uint32{2, 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sortRouteCandidates(tt.candidates) + + got := make([]uint32, 0, len(tt.candidates)) + for _, c := range tt.candidates { + got = append(got, c.interfaceIndex) + } + assert.Equal(t, tt.wantOrder, got) + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops_windows.go b/client/internal/routemanager/systemops/systemops_windows.go index 7bce6af80..47d556cf6 100644 --- a/client/internal/routemanager/systemops/systemops_windows.go +++ b/client/internal/routemanager/systemops/systemops_windows.go @@ -882,26 +882,40 @@ func getInterfaceMetric(interfaceIndex uint32, family int16) int { return int(ipInterfaceRow.Metric) } -// sortRouteCandidates sorts route candidates by priority: prefix length -> route metric -> interface metric +// sortRouteCandidates sorts route candidates by priority: prefix length -> combined metric -> route metric. +// Windows prefers the longest matching prefix and, among prefixes of the same length, the lowest metric, see +// https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-tcpip-interfaces-interface-routes-route-metric func sortRouteCandidates(candidates []candidateRoute) { sort.Slice(candidates, func(i, j int) bool { if candidates[i].prefixLength != candidates[j].prefixLength { return candidates[i].prefixLength > candidates[j].prefixLength } - if candidates[i].routeMetric != candidates[j].routeMetric { - return candidates[i].routeMetric < candidates[j].routeMetric + mi, mj := combinedMetric(candidates[i]), combinedMetric(candidates[j]) + if mi != mj { + return mi < mj } - return candidates[i].interfaceMetric < candidates[j].interfaceMetric + return candidates[i].routeMetric < candidates[j].routeMetric }) } +// combinedMetric returns the effective metric Windows uses to rank routes with an equal prefix length: +// the sum of the route metric and the metric of the interface the route is on, see +// https://learn.microsoft.com/en-us/windows-server/networking/technologies/network-subsystem/net-sub-interface-metric +// An unknown interface metric contributes nothing. +func combinedMetric(candidate candidateRoute) uint64 { + if candidate.interfaceMetric < 0 { + return uint64(candidate.routeMetric) + } + return uint64(candidate.routeMetric) + uint64(candidate.interfaceMetric) +} + // GetBestInterface finds the best interface for reaching a destination, // excluding the VPN interface to avoid routing loops. // // Route selection priority: // 1. Longest prefix match (most specific route) -// 2. Lowest route metric -// 3. Lowest interface metric +// 2. Lowest combined metric (route metric + interface metric) +// 3. Lowest route metric. func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { var skipInterfaceIndex int if vpnIntf != "" { @@ -925,7 +939,6 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { return nil, fmt.Errorf("no route to %s", dest) } - // Sort routes: prefix length -> route metric -> interface metric sortRouteCandidates(candidates) for _, candidate := range candidates { From f458c1f26563f2eacec2c9b9b5a8b7fbd1044bf2 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sat, 15 Aug 2026 08:13:06 +0000 Subject: [PATCH 07/36] [client] Skip IPv6 route tests when the default nexthop is unusable (#7212) * [client] Skip IPv6 route tests when the default nexthop is unusable ensureIPv6DefaultRoute treated a successful netlink RouteAdd as proof that a usable IPv6 nexthop exists. Installing ::/0 via loopback can succeed while the kernel still rejects that nexthop for a concrete prefix, which surfaced on ubuntu22/20260810.260 runners as: add route to table: netlink add route: invalid argument Probe the resolved nexthop by installing and removing a discard-prefix route through the same code path the tests use, and skip when it fails. EEXIST means the nexthop already carries a route, so it counts as usable. * [client] Probe the IPv6 nexthop through raw netlink addRoute swallows EAFNOSUPPORT and EOPNOTSUPP via isOpErr, so a nil return did not prove the probe route was installed. Call netlink directly so an unsupported operation skips the test instead of passing as usable. --- .../systemops/v6route_linux_test.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go index 449d4cbd2..d8c0012d1 100644 --- a/client/internal/routemanager/systemops/v6route_linux_test.go +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -5,6 +5,7 @@ package systemops import ( "errors" "net" + "net/netip" "syscall" "testing" @@ -29,6 +30,7 @@ func ensureIPv6DefaultRoute(t *testing.T) { } if err := netlink.RouteAdd(route); err != nil { if errors.Is(err, syscall.EEXIST) { + requireUsableIPv6Nexthop(t) return } t.Skipf("install IPv6 fallback default route: %v", err) @@ -38,4 +40,36 @@ func ensureIPv6DefaultRoute(t *testing.T) { t.Logf("delete IPv6 fallback default route: %v", err) } }) + + requireUsableIPv6Nexthop(t) +} + +// requireUsableIPv6Nexthop skips the test unless the resolved IPv6 default +// nexthop can actually carry a route. Installing the default route succeeding +// does not imply the kernel accepts it as a nexthop for a concrete prefix. +func requireUsableIPv6Nexthop(t *testing.T) { + t.Helper() + + nexthop, err := GetNextHop(netip.IPv6Unspecified()) + if err != nil { + t.Skipf("resolve IPv6 default nexthop: %v", err) + } + + probe := &netlink.Route{ + Scope: netlink.SCOPE_UNIVERSE, + Table: syscall.RT_TABLE_MAIN, + Family: netlink.FAMILY_V6, + Dst: &net.IPNet{IP: net.ParseIP("100::64"), Mask: net.CIDRMask(128, 128)}, + } + require.NoError(t, addNextHop(nexthop, probe), "build IPv6 probe route") + + switch err := netlink.RouteAdd(probe); { + case err == nil: + if err := netlink.RouteDel(probe); err != nil && !errors.Is(err, syscall.ESRCH) { + t.Logf("delete IPv6 probe route: %v", err) + } + case errors.Is(err, syscall.EEXIST): + default: + t.Skipf("IPv6 nexthop %s unusable for route installation: %v", nexthop, err) + } } From 16544dbc584ff4f8d76ef66fa67b1a9c028d91a9 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sat, 15 Aug 2026 09:21:57 +0000 Subject: [PATCH 08/36] [client] Pass stored email as login hint from UI and keep it on logout (#7199) * [client] Pass stored email as login hint from UI and keep it on logout Follow the CLI pattern: the Wails UI now reads the account email from the user-owned profile state file and passes it as the OIDC login_hint on login and session extend, since the daemon-side fallback runs as root and cannot see the user's state file. Logout no longer deletes the stored email, so a later login preselects the account at the IdP; profile removal remains the operation that deletes it. * [client] Log ignored profile lookup errors in extend-session hint fallback --- client/internal/profilemanager/state.go | 7 ++++--- client/ui/authsession/service.go | 18 +++++++++++++++--- client/ui/services/connection.go | 24 +++++++++++------------- client/ui/services/profile.go | 5 +++-- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index fcd1c384c..ddb5dd056 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -87,9 +87,10 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { // RemoveProfileState deletes the per-profile state file (which holds the // account email used for the SSO login hint and the UI display). Called after -// a successful logout so a logged-out profile no longer shows a stale account -// email. The state file only stores the email, so deleting it is equivalent to -// clearing it; the next SSO login recreates it. A missing file is not an error. +// profile removal; logout keeps the file so the next login can pass the email +// as the login_hint. The state file only stores the email, so deleting it is +// equivalent to clearing it; the next SSO login recreates it. A missing file +// is not an error. func (pm *ProfileManager) RemoveProfileState(profileName string) error { configDir, err := getConfigDir() if err != nil { diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go index d94cef696..9c094c2a1 100644 --- a/client/ui/authsession/service.go +++ b/client/ui/authsession/service.go @@ -6,9 +6,11 @@ import ( "context" "time" + log "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" ) @@ -60,9 +62,19 @@ func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (Exten // a request from the UI implies a graphical session, which the daemon cannot detect itself req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true} - if p.Hint != "" { - h := p.Hint - req.Hint = &h + hint := p.Hint + if hint == "" { + pm := profilemanager.NewProfileManager() + if active, perr := pm.GetActiveProfile(); perr != nil { + log.Debugf("failed to get active profile for login hint: %v", perr) + } else if state, serr := pm.GetProfileState(active.ID); serr != nil { + log.Debugf("failed to get profile state for login hint: %v", serr) + } else { + hint = state.Email + } + } + if hint != "" { + req.Hint = &hint } resp, err := cli.RequestExtendAuthSession(ctx, req) diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index aa649bb6d..f78ce4c0f 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -123,8 +123,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err if p.PreSharedKey != "" { req.OptionalPreSharedKey = ptrStr(p.PreSharedKey) } - if p.Hint != "" { - req.Hint = ptrStr(p.Hint) + hint := p.Hint + if hint == "" && profileID != "" { + if state, serr := profilemanager.NewProfileManager().GetProfileState(profilemanager.ID(profileID)); serr == nil { + hint = state.Email + } else { + log.Debugf("failed to get profile state for login hint: %v", serr) + } + } + if hint != "" { + req.Hint = ptrStr(hint) } resp, err := cli.Login(ctx, req) @@ -228,16 +236,6 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { return s.classifyDaemonError(err) } - // The daemon runs as root and can't reach the user-owned per-profile state - // file holding the account email (see Profiles.List), so clear the stale - // email here; the next SSO login recreates it. - if p.ProfileName != "" { - if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil { - // Non-fatal: the logout itself succeeded. - log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err) - } - } - return nil } @@ -261,7 +259,7 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, // Persist the account email the same way the CLI does after its own // WaitSSOLogin: the daemon returns it but cannot store it, since it runs as - // root and the per-profile state file is user-owned (see Logout below). + // root and the per-profile state file is user-owned (see Profiles.List). // Without this the profile has no email, so Profiles.List shows no account // and later logins and session extends go out without a login_hint — // leaving the IdP to guess which account was meant. diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go index 5a9a0e68d..e76ab3db6 100644 --- a/client/ui/services/profile.go +++ b/client/ui/services/profile.go @@ -162,8 +162,9 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error { } // The daemon deletes what it owns but runs as root, so it leaves the - // user-owned state file holding the account email behind (same split as - // Connection.Logout). Legacy profiles are keyed by name rather than by a + // user-owned state file holding the account email behind. Logout keeps the + // email on purpose so later logins can pass it as the login_hint; profile + // removal is what deletes it. Legacy profiles are keyed by name rather than by a // generated ID, so a recreated profile of the same name would inherit the // deleted one's email and offer it as the login_hint. // From 93e97f4bf1ad715072dcb3fb6cdb1763431b5a9c Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Sat, 15 Aug 2026 19:31:49 +0200 Subject: [PATCH 09/36] [doc] Agent network docs update (#7020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [docs] Update agent-network docs for management-owned pricing The docs still described the retired proxy-side pricing: pricing.Loader, pricing_path, MiddlewareDataDir, embedded defaults_pricing.yaml, and the symlink-safe Unix loader. Rewrite them for the current design — management synthesizes the whole table and ships it in cost_meter's ConfigJSON, so the proxy carries no price list and has nothing to reload. --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 2 +- SECURITY.md | 2 +- docs/agent-networks/01-end-to-end-flows.md | 2 +- .../modules/21-management-agentnetwork.md | 98 +++++++- .../modules/31-proxy-middleware-builtin.md | 73 ++++-- .../modules/32-proxy-llm-parsers.md | 210 +++++++++++------- .../modules/33-proxy-runtime.md | 5 +- .../modules/50-path-routed-providers.md | 44 ++-- funding.json | 12 +- shared/management/http/api/openapi.yml | 2 +- shared/management/http/api/types.gen.go | 6 +- 13 files changed, 320 insertions(+), 140 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 95b02a91d..5497acb15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # NetBird Agent Guidelines -**NetBird** is an open-source connectivity platform: a WireGuard®-based overlay +**NetBird** is an open source connectivity platform: a WireGuard®-based overlay network with a control plane. The **agent** (`client/`) runs on user machines as a privileged daemon and manages the WireGuard interface, routing, firewall, and DNS. **Management** (`management/`) is the control plane and REST/gRPC API, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 233e7a442..9dea37ec8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -479,7 +479,7 @@ go test -race ./client/internal/dns/... ## Checklist before submitting a PR -As a critical network service and open-source project, we must enforce a few +As a critical network service and open source project, we must enforce a few things before submitting a pull request. The [pull request template](/.github/pull_request_template.md) mirrors this list — fill it in rather than deleting it. diff --git a/README.md b/README.md index 40c6b9ed5..3bcb4a035 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ In November 2022, NetBird joined the [StartUpSecure program](https://www.forschu ![CISPA_Logo_BLACK_EN_RZ_RGB (1)](https://user-images.githubusercontent.com/700848/203091324-c6d311a0-22b5-4b05-a288-91cbc6cdcc46.png) ### Acknowledgements -We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing). +We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing). ### Legal This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/. diff --git a/SECURITY.md b/SECURITY.md index bdf88d670..cbcc975ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,7 @@ Report security issues one of these two ways: on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place. - **Email** — `security@netbird.io`. -If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than +If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than filing a repository report. ### What to include diff --git a/docs/agent-networks/01-end-to-end-flows.md b/docs/agent-networks/01-end-to-end-flows.md index b8891001b..0de6b4c33 100644 --- a/docs/agent-networks/01-end-to-end-flows.md +++ b/docs/agent-networks/01-end-to-end-flows.md @@ -115,7 +115,7 @@ sequenceDiagram Resp->>Resp: parse usage tokens, completion Note over Resp: capture_completion gates raw
completion capture Resp->>Cost: tokens - Cost->>Cost: lookup pricing.yaml + compute cost + Cost->>Cost: lookup rates from config-delivered
pricing table + compute cost Cost->>Rec: tokens + cost Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user) Rec-->>Log: emit access-log entry
(if EnableLogCollection) diff --git a/docs/agent-networks/modules/21-management-agentnetwork.md b/docs/agent-networks/modules/21-management-agentnetwork.md index cc74206e9..f91c369f7 100644 --- a/docs/agent-networks/modules/21-management-agentnetwork.md +++ b/docs/agent-networks/modules/21-management-agentnetwork.md @@ -15,6 +15,10 @@ Inside the package: `manager.go` is the CRUD + permissions-gated facade; `synthe | ---- | ---- | | `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger | | `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain | +| `agentnetwork/synthesizer_pricing.go` | `buildCostMeterConfigJSON` — default table + per-provider prices → `cost_meter` config | +| `agentnetwork/pricing/defaults.go` | Default pricing table derived from the catalog + supplementals; `DefaultTable`, `LookupDefault`, wire `Entry` | +| `agentnetwork/pricing/override.go` | `LoadFile`/`StartReloader` for `AgentNetwork.PricingDefaultsFile` (mtime poll, merge over compiled-in base) | +| `agentnetwork/pricing/{exampleyaml,gen}.go` | Generates `defaults_llm_pricing.example.yaml` from the compiled-in table (golden-tested) | | `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) | | `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete | | `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) | @@ -48,6 +52,8 @@ flowchart TD I --> J[indexProviderGroups: providerID -> sorted source groups] J --> K[buildRouterConfigJSON drops orphan providers] J --> L[buildIdentityInjectConfigJSON per catalog entry] + J --> K2[buildCostMeterConfigJSON: default table + per-provider prices] + K2 --> P H --> M[mergeGuardrails: union allowlist, OR redact] M --> N[applyAccountCollectionControls account toggle = SOLE capture control] N --> O[marshalGuardrailConfig] @@ -60,6 +66,84 @@ flowchart TD R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map] ``` +### LLM pricing (management is the sole authority) + +**The proxy carries no price list.** Management synthesizes the entire pricing +table and ships it inside `cost_meter`'s `ConfigJSON`, so a price change reaches +the proxies as an ordinary mapping push — the chain rebuild installs a fresh +table and there is nothing to reload on the proxy side. + +```mermaid +flowchart TD + A[catalog.All — PricingSurfaces x Models] --> B[buildDefaultTable + supplementalDefaults] + B --> C{AgentNetwork.PricingDefaultsFile} + C -- absent --> D[compiled-in table serves] + C -- loaded --> E[LoadFile: merge file entries WHOLE over compiled base] + E --> F[mergedTable atomic.Pointer] + D --> G[DefaultTable] + F --> G + G --> H[buildCostMeterConfigJSON — pricing.defaults] + I[types.Provider.Models operator prices] --> J[normalizePricingModelID
bedrock ARN/region/version, vertex @version] + J --> K[materializeEntry: default entry as base,
operator input/output verbatim,
cache pointers only when non-nil] + K --> L[pricing.providers keyed by provider record ID] + H --> M[cost_meter ConfigJSON] + L --> M + G --> N[GET /catalog — applyDefaultPricing prefills dashboard rows] + O[StartReloader: mtime poll every ReloadInterval 1m] --> E +``` + +**Two tiers, resolved per request on the proxy** (`synthesizer_pricing.go:22-35`): + +- `pricing.defaults` — surface (`openai`/`anthropic`/`bedrock`) → normalized model + id → rates. The **full** default table ships to every account: it is small + (~10 KB) and it is what keeps gateway-style providers (which enumerate no + models, so they claim every model) priced. +- `pricing.providers` — provider **record** id → normalized model id → rates, + matched against the `llm.resolved_provider_id` the router stamps. Entries are + **fully materialized here**, at synth time: `materializeEntry` starts from the + default entry for that model so cache rates the operator didn't state are + inherited, overlays operator `input`/`output` verbatim (**including an explicit + 0**, which prices a self-hosted or internal endpoint as free rather than + silently reverting to list price), and overlays cache-rate **pointers only when + non-nil** — `nil` means "inherit the default", an explicit `0` means "no + discount, bill this bucket at the input rate". The proxy therefore does two map + lookups and no merging. + +Same orphan rule as the router: a provider no enabled policy authorises is +unreachable, so its prices aren't shipped. Model ids are normalized with the +**same** functions the request parser uses (`NormalizeBedrockModel` / +`NormalizeVertexModel`), which is what makes the per-record lookup key compare +equal to the `llm.model` the proxy meters. Post-normalization duplicates resolve +first-occurrence-wins, matching the routing dedup order. + +**`AgentNetwork.PricingDefaultsFile`** (`config.go:190-207`) lets an operator +replace default rates without a rebuild. Schema is `surface → model → rates` +(`input_per_1k`, `output_per_1k`, and optional `cached_input_per_1k` / +`cache_read_per_1k` / `cache_creation_per_1k`). Semantics: + +- A **relative** path resolves against ``, so a bare filename lands + alongside the store. Empty config probes `/defaults_llm_pricing.yaml`. +- An **explicitly configured** path is *required to load*: a typo or malformed + file fails startup, because the operator believes those rates are live. The + conventional probe is optional — an absent file just serves compiled-in + defaults, and the path stays watched in case it appears later. +- File entries **replace** the compiled-in entry for the same (surface, model) + **whole** — they are not field-merged, so an entry must repeat the cache rates + it wants to keep. Everything the file doesn't mention keeps built-in rates. +- Unknown YAML fields are rejected (`KnownFields(true)`) and every rate must be + finite and non-negative — the same constraints the HTTP API enforces on + operator per-provider prices. +- Reload is an mtime poll (`ReloadInterval`, 1 min) and is **lenient at runtime**: + a parse error keeps the previous table, a deleted file reverts to compiled-in + defaults. A mid-edit save can never take pricing down. + +The live table feeds **both** consumers, which is what keeps them consistent: the +synthesizer (what proxies actually bill with) and `GET /api/agent-network/catalog` +via `applyDefaultPricing` (what the dashboard's model-row prices prefill with). +`defaults_llm_pricing.example.yaml` is generated from the compiled-in table +(`go generate ./management/internals/modules/agentnetwork/pricing`) and +golden-tested, so operators start from a file matching the built-in rates exactly. + ### Budget rule resolution (min-wins, group+user bound) ```mermaid @@ -124,7 +208,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest | on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** | | 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 | 6 | `cost_meter` | `{"pricing":{"defaults":{surface:{model:rates}},"providers"?:{providerRecordID:{model:rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}` | – | | on_response | 7 | `llm_response_parser` | `{"capture_completion": , "redact_pii"?: true}` | – | - **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=.`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`. @@ -139,6 +223,12 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest - **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry. - **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`). - **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced. +- **Management is the sole pricing authority.** The proxy has no embedded price list, so an account whose `cost_meter` config carries no `pricing` block bills **nothing** (`cost.skipped=unknown_model`, $0) rather than falling back to stale built-ins. The top-level `pricing` wrapper is also the feature-detection signal in both directions: an old proxy ignores it as an unknown field, and a new proxy reads its absence as "old management". +- **Per-provider prices are materialized at synth time, not merged on the proxy** (`synthesizer_pricing.go:114-131`). A per-record entry is always complete, so the proxy's lookup is per-record-then-defaults with no field-level fallback between tiers. +- **An explicit operator price of `0` prices the model as free** — it must not be treated as "unset" and reverted to list price (`synthesizer_pricing.go:49-54`). Only *cache*-rate fields distinguish unset from zero, via `*float64`. +- **Pricing model ids are normalized with the same functions the request parser uses** (`normalizePricingModelID`). If the two ever diverge, per-record prices silently stop matching and every request falls through to surface defaults. +- **The default table's coverage is structural, not curated.** It is derived from the catalog via each provider's `PricingSurfaces`; `TestDefaultTable_CoversEveryCatalogModel` fails on an unpriced catalog model and `TestDefaultTable_NoConflictingContributions` fails if two providers contribute the same (surface, model) at different rates. +- **A pricing-defaults file failure is fatal only at startup, and only for an explicitly configured path.** Runtime reload failures keep the previous table; a deleted file reverts to compiled-in defaults (`pricing/override.go:62-81, 113-148`). ## Things to scrutinize @@ -176,10 +266,12 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest - **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value. - **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating. - **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out. +- **`cost_meter`'s `pricing` block is version-skew-safe in both directions.** A proxy predating config-delivered pricing ignores the field as unknown JSON (it previously priced from its own embedded table, so it keeps billing — at its own rates, which is the skew to be aware of during a rolling upgrade). A current proxy paired with old management sees no `pricing` block, logs one warning at chain-build time, and records `cost.skipped=unknown_model` — token counting and cap enforcement are unaffected, only the USD annotation goes to $0. ### Performance - **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions. +- **The full default pricing table is marshalled into every account's `cost_meter` config on every synth** (~10 KB serialized). This is a deliberate trade: it keeps gateway-style providers priced for every catalog model, and it is the largest single contributor to the synth JSON. `DefaultTable()` itself is a pointer load (or a `sync.Once`-built map) — the cost is the marshal, not the build. - **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant. - **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect. @@ -188,6 +280,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest - **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume). - **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`). - **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden. +- **Pricing-file lifecycle logs at info** (load, reload, revert-to-built-ins) and **at warn** for a runtime reload failure; the mtime check itself is `Debugf`. There is no metric on reload failures, so an operator who breaks the file mid-flight keeps billing at the previous table with only a log line to show it (`pricing/override.go:113-148`). ## Test coverage @@ -198,6 +291,9 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest | `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. | | `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. | | `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. | +| `synthesizer_pricing_test.go` | `BuildCostMeterConfig_{BedrockModelNormalization,CacheRateNilVsZero,OrphanAndGatewayProviders}` — the per-record tier's three load-bearing rules: keys normalized like the parser's, `nil` cache pointer inherits vs explicit `0` bills at input rate, and orphan / gateway (empty `Models`) providers ship no per-record entry. | +| `pricing/defaults_test.go` | `DefaultTable_{CoversEveryCatalogModel,NoConflictingContributions,AllRatesFiniteNonNegative,PinnedRates}`; `LookupDefault_SurfaceOrder`. Catalog-derived coverage + rate sanity are structural, not curated. | +| `pricing/override_test.go` | `LoadFile_{MergesOverCompiledDefaults,MissingPath,RejectsInvalid}`; `Reload_LifeCycle` (mtime detect, parse error keeps previous, delete reverts to built-ins); `ExampleYAML_InSyncWithBuiltins` golden. | | `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. | | `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. | | `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. | diff --git a/docs/agent-networks/modules/31-proxy-middleware-builtin.md b/docs/agent-networks/modules/31-proxy-middleware-builtin.md index efe1bc4ce..ad56feb77 100644 --- a/docs/agent-networks/modules/31-proxy-middleware-builtin.md +++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md @@ -5,7 +5,7 @@ LLM request. The two highest-blast-radius areas are the **capture-pointer semantics** and the **limit_check ⇒ limit_record** record-once invariant. Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK -adapters + pricing catalog this chain delegates to. +adapters + pricing table and cost formula this chain delegates to. --- @@ -34,7 +34,7 @@ rewrites. | `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite | | `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) | | `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none | -| `cost_meter` | OnResponse | `llm.{provider,model}`, token buckets | `cost.usd_total` or `cost.skipped` | pricing lookup | +| `cost_meter` | OnResponse | `llm.{provider,model,resolved_provider_id}`, token buckets | `cost.usd_{input,cached_input,cache_creation,output,total,cache}` or `cost.skipped` | none (in-memory pricing lookup) | | `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` | [all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go) @@ -44,7 +44,7 @@ locks the ID set; adding or removing one is a conscious extension. | File | LOC | Notes | |---|---:|---| -| `builtin.go` | 86 | Registry + `FactoryContext` (ctx, data dir, meter, logger, mgmt client) | +| `builtin.go` | 90 | Registry + `FactoryContext` (ctx, meter, logger, mgmt client) | | `all_test.go` | 41 | Locks the 8-ID registry surface | | `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path | | `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating | @@ -53,7 +53,7 @@ locks the ID set; adding or removing one is a conscious extension. | `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders | | `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction | | `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit | -| `cost_meter/*` | 181 / 84 / 439 | Token → USD via `proxy/internal/llm/pricing` | +| `cost_meter/*` | 236 / 98 / 586 | Token → USD via `proxy/internal/llm/pricing`; both pricing tiers arrive in the middleware config | | `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) | ## Per-middleware @@ -168,12 +168,46 @@ token schema. ### cost_meter -Reads `llm.provider` + `llm.model` + token buckets, looks up per-1k rate via -`pricing.Loader`, emits `cost.usd_total` or a closed-set `cost.skipped` -reason (`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`, -`unknown_model`). Loader's hot-reload goroutine is bound to proxy-lifetime -context via `startReloader`. **Key invariant:** provider-shape switch lives -in `pricing.Table.Cost` (sibling doc) — `cost_meter` stays provider-agnostic. +Reads `llm.provider` + `llm.model` + token buckets, looks up the per-1k rates, +and emits the full `cost.usd_*` breakdown (four per-bucket values plus the +`_total` and `_cache` aggregates) or a closed-set `cost.skipped` reason +(`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`, +`unknown_model`). + +**Management owns pricing.** The proxy carries no embedded price list: the whole +table arrives in this middleware's `ConfigJSON` as +`{pricing: {defaults, providers}}`, synthesized by management from the catalog +plus the operator's stored per-provider prices +([factory.go:13–34](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)). +Both tiers are validated by `pricing.NewTable` / `pricing.NewEntries` at +construction, so a non-finite or negative rate fails the chain build. A price +change is an ordinary mapping push — the chain rebuild yields a fresh instance +over a fresh immutable table, so there is no data dir, no pricing file, no +reload goroutine, and nothing to invalidate. + +**Two-tier lookup** +([middleware.go:165–183](../../../proxy/internal/middleware/builtin/cost_meter/middleware.go)): + +1. **Per-provider-record** — the operator's stored price for the route that + actually served the request, keyed by the `llm.resolved_provider_id` that + `llm_router` stamped on the allow path, then by normalized model id. Entries + arrive fully materialized (management folds default cache rates in at synth + time), so there is no merging here. Absent metadata — no router in the chain + — skips this tier. +2. **Surface defaults** — the catalog-derived table keyed by `llm.provider` + (`openai`/`anthropic`/`bedrock`). This is also what prices gateway-style + providers, which enumerate no models and therefore get no per-record entry. + +**Backward compatibility:** a config with no `pricing` block means management +predates config-delivered pricing. The factory logs one warning at build time +and the instance records `cost.skipped=unknown_model` ($0) for every request +rather than falling back to a stale built-in price list +([factory.go:55–60](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)). + +**Key invariant:** the provider-shape switch lives in `pricing.EntryCosts` +(sibling doc) and is selected by the **surface**, not by which tier the entry +came from — `cost_meter` stays provider-agnostic, and a per-record override on +an Anthropic route still bills its cache buckets additively. ### llm_limit_record @@ -246,12 +280,14 @@ no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter` | `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` | | `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`) | +| `cost_meter` | `{pricing: {defaults: {surface: {model: rates}}, providers: {providerRecordID: {model: rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}`. A missing `pricing` key means "management predates config-delivered pricing": every request records `cost.skipped=unknown_model` | | `llm_limit_record` | `{}` — same pattern as `llm_limit_check` | All factories accept empty / null / `{}` / whitespace as zero-value config; only structurally invalid JSON is rejected so misconfig surfaces at chain -build time. +build time. `cost_meter` adds a semantic check on top of that: a `pricing` +block carrying a negative or non-finite rate fails the build too, rather than +mispricing live traffic. ## Invariants @@ -320,10 +356,11 @@ non-object `metadata` field — header path still attributes, but body-level tag-budget enforcement doesn't run for that request. -**Concurrency.** `cost_meter` shares a `pricing.Loader` via -`atomic.Pointer[Table]`; readers always see a consistent table. Every -middleware is a stateless value receiver. Integration test uses real bufconn -gRPC — race detector is the meaningful bar. +**Concurrency.** `cost_meter`'s two pricing tables are built once from the +middleware config and never mutated, so the lookup path needs no lock or atomic +swap — a price change replaces the whole instance. Every middleware is +otherwise a stateless value receiver. Integration test uses real bufconn gRPC — +race detector is the meaningful bar. **Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost` is O(1); SSE accumulation is single-pass. No map allocation per call. @@ -349,13 +386,13 @@ counter accuracy. | `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven | | `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation | | `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort | -| `cost_meter/middleware_test.go` | 17 | Each skip reason, provider-shape, pricing loader integration | +| `cost_meter/middleware_test.go` | 22 | Each skip reason, provider-shape formulas, config-delivered defaults, per-record-beats-defaults + miss-falls-back, per-record uses surface formula, nil-pricing skips everything, invalid-rate rejection | | `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed | ## Cross-references - Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters - + SSE framer + pricing loader. + + SSE framer + pricing table and cost formula. - Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP token minting, `/bedrock` prefix: [50-path-routed-providers.md](./50-path-routed-providers.md). diff --git a/docs/agent-networks/modules/32-proxy-llm-parsers.md b/docs/agent-networks/modules/32-proxy-llm-parsers.md index 0376bc988..52faeaac1 100644 --- a/docs/agent-networks/modules/32-proxy-llm-parsers.md +++ b/docs/agent-networks/modules/32-proxy-llm-parsers.md @@ -9,7 +9,7 @@ pricing table's per-provider cost formula is the highest-leverage place a small bug would silently mis-bill operators. Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) -— the 8 middlewares that consume this package's parsers + pricing loader. +— the 8 middlewares that consume this package's parsers + pricing table. --- @@ -24,8 +24,9 @@ proxy-framework dependencies: - `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls. - `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`). - `errors.go` — sentinels callers branch on with `errors.Is`. -- `pricing/` — embedded-default + hot-reload override table with - symlink-safe Unix loader (build-tagged stub elsewhere). +- `pricing/` — immutable pricing table + the per-surface cost formula. The + rates themselves come from management inside `cost_meter`'s middleware + config; this package holds no price list and reads no files. - `fixtures/` — captured request/response/stream bodies the tests replay. The package carries zero proxy-framework dependencies so the same parsers can @@ -47,12 +48,9 @@ be reused later by a WASM adapter | `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits | | `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values | | `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` | -| `pricing/pricing.go` | 421 | `Loader`, `Table`, `Entry`; embedded defaults + atomic swap + mtime reload | -| `pricing/pricing_unix.go` | 69 | `O_NOFOLLOW` + fstat-from-FD + 1 MiB cap | -| `pricing/pricing_other.go` | 21 | Stub returning "not supported on this platform" | -| `pricing/pricing_test.go` | 432 | 21 tests — symlink rejection, reload race, path traversal, oversize | -| `pricing/defaults_pricing.yaml` | 85 | go:embed source of truth | -| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream + pricing starter | +| `pricing/pricing.go` | 234 | `Table`, `Entry`, `EntryJSON`, `Costs`; `NewTable`/`NewEntries` validation + `EntryCosts` formula. No I/O, no reload, no embedded rates | +| `pricing/pricing_test.go` | 177 | 10 tests — provider-shape formulas, cached clamp, rate fallback, nil-safety, rate validation | +| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream | ## Request body → parser dispatch @@ -188,9 +186,11 @@ response leg, covering both Bedrock body shapes: `totalTokens`). `firstNonZero` folds the two naming conventions into one `Usage`; when Converse omits `totalTokens` the parser sums the buckets. -`ProviderName()` returns `"bedrock"` — its own `defaults_pricing.yaml` block, -keyed by the **normalised** model id (region prefix + version suffix stripped by -the request parser). `ParseResponse` returns `ErrStreamingUnsupported` for an +`ProviderName()` returns `"bedrock"` — its own pricing surface in the table +management ships, keyed by the **normalised** model id (region prefix + version +suffix stripped by the request parser; management normalises its keys the same +way at synth time so the two compare equal). `ParseResponse` returns +`ErrStreamingUnsupported` for an AWS binary event-stream content-type (`application/vnd.amazon.eventstream`, `isAWSEventStream`) so the caller routes to the streaming accumulator instead. @@ -205,11 +205,34 @@ response body. Streaming accumulators live in the middleware package ([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)) but use `llm.NewScanner` so the framing contract stays here. -### Pricing catalog +### Pricing table -`Table.Cost` -([pricing.go:129–174](../../../proxy/internal/llm/pricing/pricing.go)) -is the cost formula — most security-relevant math in this module: +**Management is the sole pricing authority.** The proxy carries no embedded +price list and reads no pricing file: the whole table arrives inside +`cost_meter`'s `ConfigJSON` on the ordinary mapping push, and a price change +is just another push — the chain rebuild constructs a fresh `Table`, so there +is nothing to reload +([pricing.go:1–7](../../../proxy/internal/llm/pricing/pricing.go)). The +management side of the contract (catalog defaults, the operator's stored +per-provider prices, and `AgentNetwork.PricingDefaultsFile`) is covered in the +management-side module guide; `cost_meter`'s wire shape is in +[31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md). + +`EntryJSON` +([pricing.go:36–45](../../../proxy/internal/llm/pricing/pricing.go)) is the +management→proxy contract — five USD-per-1k rates under `input_per_1k`, +`output_per_1k`, `cached_input_per_1k`, `cache_read_per_1k`, +`cache_creation_per_1k`. Management's `pricing.Entry` marshals the identical +names, and `EntryJSON`/`Entry` are field-identical so `NewEntries` converts by +direct struct conversion rather than field-by-field copying (a new rate can't +be silently dropped in transit). + +`EntryCosts` +([pricing.go:183–234](../../../proxy/internal/llm/pricing/pricing.go)) +is the cost formula — most security-relevant math in this module. The +**surface** (the `llm.provider` value the request parser stamped) selects the +formula, never the tier the entry came from: a per-provider-record override on +an Anthropic route still bills its cache buckets additively. | Provider | Formula | |---|---| @@ -218,7 +241,7 @@ is the cost formula — most security-relevant math in this module: | default | `inTokens × InputPer1K + outTokens × OutputPer1K` | `bedrock` shares the Anthropic additive-cache formula -([pricing.go:172-174](../../../proxy/internal/llm/pricing/pricing.go)): +([pricing.go:214–229](../../../proxy/internal/llm/pricing/pricing.go)): Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces to `input + output`. @@ -226,15 +249,12 @@ to `input + output`. Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in to discounts by setting the field. -`Loader` -([pricing.go:212–268](../../../proxy/internal/llm/pricing/pricing.go)) -overlays an optional `pricing.yaml` from data-dir on top of the go:embed -defaults. Atomic pointer swap means readers never observe a partial update. -The mtime-poll reloader (30s default cadence) keeps the previous table on -parse failure so cost annotation never goes blank during a botched edit. - -`defaults_pricing.yaml` is the source of truth for built-in pricing. -Operator overrides only carry the entries they want to change. +`Costs` +([pricing.go:143–163](../../../proxy/internal/llm/pricing/pricing.go)) is the +per-request split. The four per-bucket fields are the base; `TotalUSD` and +`CacheUSD` are **derived** in `newCosts` so the aggregates can never drift from +the breakdown. `InputUSD` is always the non-cached input bucket on both +provider shapes, so input and cached-input never double-count. ## Public contracts @@ -264,29 +284,38 @@ Order matters: `DetectFromURL` ties resolve by registration order. `ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat them as wire-stable — new providers must take fresh numbers. -**`Pricing` lookup** -([pricing.go:129](../../../proxy/internal/llm/pricing/pricing.go)): +**`Pricing` construction + lookup** +([pricing.go:60–130](../../../proxy/internal/llm/pricing/pricing.go)): ```go +func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error) +func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) + +func (t *Table) Lookup(provider, model string) (Entry, bool) func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) +func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) +func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs ``` -Nil-safe: `t.Cost` on a nil receiver returns `(0, false)` -([pricing.go:130–132](../../../proxy/internal/llm/pricing/pricing.go)). -`ok=false` means provider or model is absent from the loaded table; the caller -emits `cost.skipped=unknown_model`. +`NewTable` is the surface-keyed defaults table; `NewEntries` returns the raw +two-level map `cost_meter` uses for the per-provider-record tier (it looks up an +`Entry` directly and calls `EntryCosts`, so it needs no `Table` wrapper). Both +reject any non-finite or negative rate, so a corrupt config fails the chain +build rather than mispricing silently. Nil input yields an empty, +never-matching table. + +Nil-safe: `t.Cost`/`t.Lookup` on a nil receiver returns `ok=false` +([pricing.go:96–99](../../../proxy/internal/llm/pricing/pricing.go)). +`ok=false` means the surface or model is absent from the table management sent; +the caller emits `cost.skipped=unknown_model`. ## Invariants -1. **Cross-platform pricing build.** `pricing_unix.go` carries the only - functional `loadPricing` (uses `syscall.O_NOFOLLOW` and `f.Stat()` on an - open descriptor — both Unix-only). `pricing_other.go` is a build-tag - fallback that returns `"not supported on this platform"` - ([pricing_other.go:14–16](../../../proxy/internal/llm/pricing/pricing_other.go)). - The proxy is Linux-only in production today; a Windows port needs an - equivalent path-as-handle implementation. Reviewers building on Windows - should expect this surface to return an error at startup if an override - file is configured. +1. **The pricing package is pure and platform-independent.** No file I/O, no + `//go:embed`, no goroutines, no build tags — the rates arrive as config, so + there is nothing platform-specific left to port. Anything reintroducing a + read-from-disk path here re-splits pricing authority between management and + the proxy, which is exactly what this design removed. 2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end in `\n\n` still yields its accumulated event before `io.EOF` @@ -298,38 +327,45 @@ emits `cost.skipped=unknown_model`. usage rather than aborting ([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)). -3. **`defaults_pricing.yaml` is the source of truth.** Compiled into the - binary via `//go:embed` - ([pricing.go:29–30](../../../proxy/internal/llm/pricing/pricing.go)). - `DefaultTable()` parses once and panics on parse failure - ([pricing.go:42–49](../../../proxy/internal/llm/pricing/pricing.go)) - — by design: a broken embedded YAML must not ship to production. +3. **Management is the only source of rates.** `Table` has no constructor that + invents prices: the only way in is `NewTable`/`NewEntries` over the wire map + management sent. A missing or empty `pricing` block therefore means *no + prices at all* (`cost_meter` records `cost.skipped=unknown_model`, $0) — + never a stale built-in fallback that would silently bill list price. -4. **Loader path validation.** `resolveMiddlewareDataPath` - ([pricing.go:370–394](../../../proxy/internal/llm/pricing/pricing.go)) - rejects absolute paths, traversal segments, and basenames that fail - `basenameRegex = ^[a-zA-Z0-9._-]+$`. The resolved path must remain - inside `baseDir` even after `filepath.Clean`. Tests: - `TestNewLoader_PathValidation`, `TestNewLoader_PathValidation_Extended`, - `TestNewLoader_SymlinkOutsideBaseDirRejected`, `TestNewLoader_SymlinkRejected`. +4. **Tables are immutable once built.** `Table.entries` is written only in + `NewEntries` and never mutated afterwards, and `cost_meter`'s `perRecord` + map is likewise build-time-only + ([pricing.go:47–52](../../../proxy/internal/llm/pricing/pricing.go)). This + is what makes the no-reload design safe: a price change arrives as a mapping + push that builds a new middleware instance over a new table, so concurrent + readers can't observe a half-updated price list and no atomic swap or lock + is needed on the hot path. -5. **Unix loader symlink safety.** `O_NOFOLLOW` on open, `f.Stat()` on the - open descriptor (never re-stat by path), `info.Mode().IsRegular()` check, - `io.LimitReader(f, maxPricingBytes+1)` with a final size assertion - ([pricing_unix.go:25–57](../../../proxy/internal/llm/pricing/pricing_unix.go)). - A mid-read symlink swap is detected because the fstat is on the original - fd. Test: `TestNewLoader_RejectsOversizedFile_FixesM4`. +5. **Rate validation happens at chain-build time, not per request.** + `NewEntries` rejects negative, NaN, and ±Inf rates field by field + ([pricing.go:60–83](../../../proxy/internal/llm/pricing/pricing.go)), naming + the offending surface/model/field in the error. Management enforces the same + constraints at its API boundary and in its YAML parser, so this is + defense-in-depth — but it means a corrupt push fails loudly at build instead + of producing negative costs on live traffic. Test: + `TestNewTable_ValidatesRates`. -6. **`yaml.NewDecoder(...).KnownFields(true)`** - ([pricing.go:397–398](../../../proxy/internal/llm/pricing/pricing.go)) - rejects YAML files that carry fields not in the schema. A typo in an - operator override file fails loud instead of silently zeroing rates. +6. **New rates must be added to `Entry`, `EntryJSON`, *and* management's + `pricing.Entry` together.** `NewEntries` converts by direct struct + conversion `Entry(e)` + ([pricing.go:76–78](../../../proxy/internal/llm/pricing/pricing.go)), which + only compiles while the two structs stay field-identical — so the proxy half + is compiler-enforced. The management half is not: a rate added there but not + here unmarshals into nothing and prices that bucket at `InputPer1K`. ## Things to scrutinise -**Correctness.** Verify OpenAI cached-prompt clamp at -[pricing.go:147–149](../../../proxy/internal/llm/pricing/pricing.go) -short-circuits before subtraction. `Anthropic.TotalTokens` sums all four +**Correctness.** Verify the OpenAI cached-prompt clamp at +[pricing.go:203–206](../../../proxy/internal/llm/pricing/pricing.go) +short-circuits before subtraction. Negative token counts are clamped to zero up +front ([pricing.go:186–197](../../../proxy/internal/llm/pricing/pricing.go)) so +no formula can yield a negative cost. `Anthropic.TotalTokens` sums all four buckets (in + out + cache_read + cache_creation) — downstream dashboards need to know this differs from `input + output`. `OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a @@ -338,22 +374,27 @@ noting). **Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event errors from `Scanner.Next` and both accumulators stop with partial usage. -Pricing file 1 MiB cap is orders of magnitude larger than realistic. Confirm -new schema additions are mirrored in both `pricingFile` and `Entry`; -`KnownFields(true)` will reject silently-typo'd operator overrides -otherwise. +Pricing is no longer file-backed, so the loader's path-traversal / symlink / +oversize surface is gone entirely — the config channel (an authenticated +mapping push from management) is now the only way rates enter the proxy, and +`NewEntries` is the validation boundary on it. A new rate added to management's +`pricing.Entry` but not to `EntryJSON` here is the remaining silent-mispricing +path (see invariant 6). -**Concurrency.** `Loader.table` is `atomic.Pointer[Table]`; readers never -block or see a torn table. `Loader.Reload` is one goroutine, cancelled via -context (`TestLoader_ReloadBackgroundLoopCancellation`). `DefaultTable()` -uses `sync.Once`. Per-call `Scanner` instances mean no shared state across -concurrent response-parser calls. +**Concurrency.** Nothing in this package is shared mutable state: tables are +built once and never written again, so `cost_meter`'s hot path is lock-free by +construction rather than by atomic swap. Per-call `Scanner` instances mean no +shared state across concurrent response-parser calls. -**Perf.** `Table.Cost` is two map lookups + multiplications, O(1). -`Scanner.Next` is one `ReadString('\n')` per line. Pricing reload poll 30s. +**Perf.** `Table.Cost` is two map lookups + multiplications, O(1); the +per-provider-record tier adds at most one more lookup. `Scanner.Next` is one +`ReadString('\n')` per line. No background goroutines and no per-request +allocation of pricing state. -**Observability.** Reload failures count via `metric.Int64Counter` keyed -`plugin`; warning log rate-limited at 5 min so a broken file doesn't flood. +**Observability.** A config carrying no `pricing` block logs one warning at +chain-build time (`cost_meter` factory) and then records +`cost.skipped=unknown_model` per request, so an old-management deployment is +visible in both logs and the access log rather than quietly reporting $0. Parser errors return sentinels — middleware uses `errors.Is` to map to the right `cost.skipped` reason. @@ -365,7 +406,7 @@ right `cost.skipped` reason. | `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays | | `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays | | `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection | -| `pricing/pricing_test.go` | 21 | Provider-shape switch; cached-rate fallback; cached-clamp; symlink rejection (target outside basedir + symlink to file); path validation matrix; oversize rejection; reload-keeps-previous-on-parse-error; mtime change detection; goroutine cancellation | +| `pricing/pricing_test.go` | 10 | Provider-shape switch (surface selects the formula); cached-rate + cache-read/creation fallback to `InputPer1K`; cached-clamp; negative-token clamp; nil-receiver safety; rate validation (negative / NaN / Inf rejected); nil + empty table | **Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)): `openai_chat_completion.json` (chat.completions with usage), @@ -373,14 +414,15 @@ right `cost.skipped` reason. `openai_stream.txt` (3 deltas + usage + `[DONE]`), `anthropic_messages.json` (Messages API non-streaming), `anthropic_stream.txt` (full 7-event sequence: message_start → -content_block_{start,delta×2,stop} → message_delta (usage) → message_stop), -`pricing.yaml` (realistic-pricing starter for operator overrides). +content_block_{start,delta×2,stop} → message_delta (usage) → message_stop). +No pricing fixture: the table is config-delivered, so pricing tests construct +it in-process from a wire-shape map. ## Cross-references - Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) — the chain that calls `llm.Parsers()`, `llm.ParserByName`, - `llm.NewScanner`, `pricing.NewLoader`. + `llm.NewScanner`, `pricing.NewTable` / `pricing.NewEntries`. - Path-routed providers (Vertex AI + Bedrock), credential syntax, and the Bedrock AWS event-stream accumulator: [50-path-routed-providers.md](./50-path-routed-providers.md). diff --git a/docs/agent-networks/modules/33-proxy-runtime.md b/docs/agent-networks/modules/33-proxy-runtime.md index f553473f8..54046b614 100644 --- a/docs/agent-networks/modules/33-proxy-runtime.md +++ b/docs/agent-networks/modules/33-proxy-runtime.md @@ -1,7 +1,7 @@ # proxy/runtime — translate + serve + log > **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target. -> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareDataDir`, `MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. +> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. Middleware config is entirely wire-delivered — no proxy-side data dir is involved, including for LLM pricing, which management ships inside `cost_meter`'s config. ## Module boundary @@ -114,8 +114,7 @@ At **request time** the access-log middleware stamps `CapturedData`; the auth ch ## Public contracts touched -- `proxy.Server.MiddlewareDataDir` (string) — base dir for file-backed middleware config (server.go:238-241). -- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:248-250). +- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:249-253). There is no `MiddlewareDataDir`: no built-in middleware reads config from disk, so `builtin.FactoryContext` carries only the proxy-lifetime context, meter, logger, and management client. - `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56). - `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default. - `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258). diff --git a/docs/agent-networks/modules/50-path-routed-providers.md b/docs/agent-networks/modules/50-path-routed-providers.md index b7cda3a97..08c976c5f 100644 --- a/docs/agent-networks/modules/50-path-routed-providers.md +++ b/docs/agent-networks/modules/50-path-routed-providers.md @@ -87,9 +87,9 @@ strips the `@version` suffix from the model, and maps the publisher to a parser surface via `vertexPublisherVendor`: - `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic - parser, priced under the **`anthropic`** block in `defaults_pricing.yaml` - (the parser emits the standard Anthropic provider label, so Vertex Claude - reuses first-party Anthropic prices). + parser, priced under the **`anthropic`** surface of the pricing table + management ships (the parser emits the standard Anthropic provider label, so + Vertex Claude reuses first-party Anthropic prices). - `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup today). - anything else (notably `google` / Gemini) → empty vendor → **no parser**. @@ -104,8 +104,9 @@ is omitted from the catalog. > Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price > premium that the base per-token rates do **not** model — cost annotations for -> those regions read low. Operators who need exact regional billing override -> the affected entries in `pricing.yaml`. +> those regions read low. Operators who need exact regional billing set the +> affected models' prices on the provider record, or replace the default entries +> via management's `AgentNetwork.PricingDefaultsFile`. ## AWS Bedrock (`bedrock_api`) @@ -211,15 +212,19 @@ so a model-listing call can't be rewritten onto an upstream that would 404 it. ## Catalog ↔ pricing cross-check Catalog prices and context windows are cross-checked against LiteLLM's -`model_prices_and_context_window.json`. The proxy's embedded -`defaults_pricing.yaml` covers **every metered first-party model** the catalog -enumerates — guarded by -`TestDefaultTable_FirstPartyModelCoverage` -([pricing/defaults_coverage_test.go](../../../proxy/internal/llm/pricing/defaults_coverage_test.go)), -which fails if a catalog model has no embedded price. Bedrock entries are keyed -by the **normalised** id the request parser emits (region prefix + version -suffix stripped). Vertex Claude carries no Bedrock-style prefix, so it prices -straight off the `anthropic` block. +`model_prices_and_context_window.json`. The **catalog is the source of default +prices**: management's `pricing.DefaultTable` folds every catalog provider's +models into the surfaces that provider declares (`PricingSurfaces`), so coverage +is structural rather than maintained in a parallel file +([pricing/defaults.go](../../../management/internals/modules/agentnetwork/pricing/defaults.go)). +`TestDefaultTable_CoversEveryCatalogModel` fails if a catalog model ends up +unpriced, and `TestDefaultTable_NoConflictingContributions` fails if two +providers contribute the same (surface, model) at different rates. Bedrock +entries are keyed by the **normalised** id the request parser emits (region +prefix + version suffix stripped) — management applies the same normalisation to +per-provider prices at synth time, so the two keys compare equal. Vertex Claude +carries no Bedrock-style prefix, so it prices straight off the `anthropic` +surface. ## Things to scrutinise @@ -232,16 +237,17 @@ operator-misconfigured Vertex provider and unmetered Gemini traffic; verify publishers). **Correctness.** `normalizeBedrockModel` is the join between the wire id and the -pricing key — a model that normalises to something not in `defaults_pricing.yaml` -meters at `cost.skipped=unknown_model` rather than failing the request. The +pricing key — a model that normalises to something absent from the shipped +pricing table meters at `cost.skipped=unknown_model` rather than failing the +request. The `/bedrock` prefix strip must run on both the parser side (so the model is extracted) and the router side (so the upstream path is native); a regression in either silently breaks the other. **Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a -~10% premium not modelled by base pricing — flagged in both the catalog comment -and `defaults_pricing.yaml`. Operators needing exact regional billing override -the relevant entries. +~10% premium not modelled by base pricing — flagged in the catalog comment. +Operators needing exact regional billing set per-provider prices on the model +rows (or replace the default entries via `AgentNetwork.PricingDefaultsFile`). ## Cross-references diff --git a/funding.json b/funding.json index 6b509a992..34ee9fe46 100644 --- a/funding.json +++ b/funding.json @@ -6,7 +6,7 @@ "name": "NetBird GmbH", "email": "hello@netbird.io", "phone": "", - "description": "NetBird GmbH is a Berlin-based software company specializing in the development of open-source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open-source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.", + "description": "NetBird GmbH is a Berlin-based software company specializing in the development of open source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.", "webpageUrl": { "url": "https://github.com/netbirdio" } @@ -15,7 +15,7 @@ { "guid": "netbird", "name": "NetBird", - "description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open-source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.", + "description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.", "webpageUrl": { "url": "https://github.com/netbirdio/netbird" }, @@ -59,7 +59,7 @@ "guid": "support-yearly", "status": "active", "name": "Support Open Source Development and Maintenance - Yearly", - "description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.", "amount": 100000, "currency": "USD", "frequency": "yearly", @@ -72,7 +72,7 @@ "guid": "support-one-time-year", "status": "active", "name": "Support Open Source Development and Maintenance - One Year", - "description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.", "amount": 100000, "currency": "USD", "frequency": "one-time", @@ -85,7 +85,7 @@ "guid": "support-one-time-monthly", "status": "active", "name": "Support Open Source Development and Maintenance - Monthly", - "description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.", "amount": 10000, "currency": "USD", "frequency": "monthly", @@ -98,7 +98,7 @@ "guid": "support-monthly", "status": "active", "name": "Support Open Source Development and Maintenance - One Month", - "description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.", "amount": 10000, "currency": "USD", "frequency": "monthly", diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 3622ee1ef..bfceadeef 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -4608,7 +4608,7 @@ components: FleetDMMatchAttributes: type: object - description: Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly + description: Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly additionalProperties: false properties: disk_encryption_enabled: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 825caad13..04e04a24f 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2876,7 +2876,7 @@ type EDRFleetDMRequest struct { // LastSyncedInterval The devices last sync requirement interval in hours. Minimum value is 24 hours LastSyncedInterval int `json:"last_synced_interval"` - // MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly + // MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly MatchAttributes FleetDMMatchAttributes `json:"match_attributes"` } @@ -2909,7 +2909,7 @@ type EDRFleetDMResponse struct { // LastSyncedInterval The devices last sync requirement interval in hours. LastSyncedInterval int `json:"last_synced_interval"` - // MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly + // MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly MatchAttributes FleetDMMatchAttributes `json:"match_attributes"` // UpdatedAt Timestamp of when the integration was last updated. @@ -3129,7 +3129,7 @@ type Event struct { // EventActivityCode The string code of the activity that occurred during the event type EventActivityCode string -// FleetDMMatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly +// FleetDMMatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly type FleetDMMatchAttributes struct { // DiskEncryptionEnabled Whether disk encryption (FileVault/BitLocker) must be enabled on the host DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty"` From 4e5b63249032dbd099f0b103009546b51ac69430 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Sun, 16 Aug 2026 16:40:20 +0200 Subject: [PATCH 10/36] [infrastructure] Don't override the dashboard image on enterprise migration (#7206) --- infrastructure_files/migrate-to-enterprise.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 5f69b4a90..e2713c902 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -221,9 +221,6 @@ render_override() { # Remove this file (and config.yaml.enterprise if present) to revert. services: - ${DASHBOARD_SERVICE}: - image: \${NETBIRD_DASHBOARD_IMAGE:-ghcr.io/netbirdio/dashboard-cloud:latest} - ${COMBINED_SERVICE}: image: \${NETBIRD_SERVER_IMAGE:-ghcr.io/netbirdio/netbird-server-cloud:latest} environment: From 70f192344b51d90f758367e53ebb8605ed7cfbc5 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 17 Aug 2026 10:10:19 +0000 Subject: [PATCH 11/36] [client] Update golang.org/x/mobile to v0.0.0-20260816165457-f98cc9b3c733 (#7229) --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index f119d4a92..beca63bfe 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 golang.org/x/sys v0.47.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 @@ -127,9 +127,9 @@ require ( go.uber.org/zap v1.27.0 goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f - golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.37.0 - golang.org/x/net v0.56.0 + golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733 + golang.org/x/mod v0.39.0 + golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 golang.org/x/term v0.45.0 @@ -313,8 +313,8 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/text v0.40.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.49.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect diff --git a/go.sum b/go.sum index 31e8b5454..99adaa2cb 100644 --- a/go.sum +++ b/go.sum @@ -728,13 +728,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20251113184115-a159579294ab h1:Iqyc+2zr7aGyLuEadIm0KRJP0Wwt+fhlXLa51Fxf1+Q= -golang.org/x/mobile v0.0.0-20251113184115-a159579294ab/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0= +golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733 h1:XKMObIaAElmkdO+4SQh1iCfzwciZHJi1OblnX9BED9k= +golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733/go.mod h1:jMwjxoDSx9jqhNaZqPnr6nnKzb7cs+Dy1Czk7wdX+R8= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -744,8 +744,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -764,8 +764,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -843,8 +843,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -858,8 +858,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 939b686d0510d93db5a632abf3046180382af98a Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:52:17 +0900 Subject: [PATCH 12/36] [client] Delete NRPT rules by enumerating the registry instead of a rule count (#7195) --- client/internal/dns/host_windows.go | 96 ++++++++++++------- client/internal/dns/host_windows_test.go | 70 ++++++++++++-- .../internal/dns/unclean_shutdown_windows.go | 10 +- 3 files changed, 128 insertions(+), 48 deletions(-) diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index d20fdd1d6..2852dddb9 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -35,6 +35,8 @@ var ( // exported so a diagnostic reader reports the same locations that are written. const ( // NRPTKeyPrefix starts the name of every NRPT rule key this client creates. + // Older versions used different layouts under the same prefix: a single + // unsuffixed key, then one key per domain, now one key per batch of domains. NRPTKeyPrefix = "NetBird-Match" // DNSPolicyConfigRoot holds the NRPT rules of the local policy store. @@ -89,7 +91,6 @@ type registryConfigurator struct { guid string routingAll bool gpo bool - nrptEntryCount int origNameservers []netip.Addr } @@ -322,14 +323,9 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager } if len(matchDomains) != 0 { - count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP) - // Update count even on error to ensure cleanup covers partially created rules - r.nrptEntryCount = count - if err != nil { + if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil { return fmt.Errorf("add dns match policy: %w", err) } - } else { - r.nrptEntryCount = 0 } r.updateState(stateManager) @@ -345,9 +341,8 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) { if err := stateManager.UpdateState(&ShutdownState{ - Guid: r.guid, - GPO: r.gpo, - NRPTEntryCount: r.nrptEntryCount, + Guid: r.guid, + GPO: r.gpo, }); err != nil { log.Errorf("failed to update shutdown state: %s", err) } @@ -362,7 +357,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error { return nil } -func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) { +func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error { // if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored // see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745 @@ -379,19 +374,17 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex) if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) + return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) } - // Increment immediately so the caller's cleanup path knows about this rule - ruleIndex++ - if r.gpo { if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err) + return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err) } } - log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains)) + log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains)) + ruleIndex++ } if r.gpo { @@ -401,7 +394,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr } log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains)) - return ruleIndex, nil + return nil } func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { @@ -534,28 +527,28 @@ func (r *registryConfigurator) restoreHostDNS() error { return nil } +// removeDNSMatchPolicies deletes every NRPT rule this client may have created, +// from the local and the GPO policy store. The rules are found by enumerating +// the registry, the only authoritative record of what was written. Cleanup must +// not depend on a rule count: the in-memory one is scoped to a single +// registryConfigurator and the persisted one is deleted on every clean +// disconnect, and a rule left behind keeps resolving names over an interface +// that is gone, until reboot discards the volatile key. func (r *registryConfigurator) removeDNSMatchPolicies() error { var merr *multierror.Error - // Try to remove the base entries (for backward compatibility) - if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err)) - } - - if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err)) - } - - for i := 0; i < r.nrptEntryCount; i++ { - localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i) - gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i) - - if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err)) + for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} { + names, err := listNRPTRuleKeys(root) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err)) + continue } - if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err)) + for _, name := range names { + path := root + `\` + name + if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err)) + } } } @@ -570,6 +563,39 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error { return r.restoreHostDNS() } +// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store +// root. An absent root holds nothing to clean up, which is the normal state of +// the GPO store on a machine without DNS Client policy. +func listNRPTRuleKeys(root string) ([]string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // the GPO store is absent on a machine without DNS client policy + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root) + return nil, nil + case err != nil: + // any other failure has to reach the caller: reporting no rules would + // report a successful cleanup while leaving the rules in place + return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err) + } + defer closer(k) + + names, err := k.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkey names: %w", err) + } + + var ruleKeys []string + for _, name := range names { + // registry key names are case insensitive + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) { + ruleKeys = append(ruleKeys, name) + } + } + + return ruleKeys, nil +} + func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE) if err != nil { diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 3cd2b1bd5..861613c95 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -25,7 +25,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -56,7 +56,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify 3 NRPT rules exist - assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains") + assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains") for i := 0; i < 3; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -81,7 +81,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify first 2 NRPT rules exist - assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains") + assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains") for i := 0; i < 2; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -106,9 +106,65 @@ func registryKeyExists(path string) (bool, error) { return true, nil } +// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run +// are removed by a configurator that has no record of how many there are: an +// unclean exit loses the in-memory count and a clean disconnect deletes the +// persisted one, so cleanup cannot depend on either. +func TestNRPTCleanupWithoutRuleCount(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + + // 75 domains produce two indexed rules, as the current layout does + domains := make([]string, 75) + for i := range domains { + domains[i] = fmt.Sprintf(".domain%d.com", i+1) + } + + previousRun := ®istryConfigurator{} + require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP)) + + // the unsuffixed key an older version would have written + require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP)) + + // a policy owned by someone else, which cleanup must not touch + foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign` + foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE) + require.NoError(t, err, "Should create foreign policy key") + foreignKey.Close() + defer func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath) + }() + + require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one") + + // a configurator that never applied a DNS config, as one built after a + // restart or from a shutdown state without a count is + freshRun := ®istryConfigurator{} + require.NoError(t, freshRun.removeDNSMatchPolicies()) + + assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run") + + exists, err := registryKeyExists(foreignPath) + require.NoError(t, err) + assert.True(t, exists, "Should not remove a policy that is not ours") +} + +func countNRPTRuleKeys(t *testing.T) int { + t.Helper() + + names, err := listNRPTRuleKeys(DNSPolicyConfigRoot) + require.NoError(t, err, "Should list NRPT rule keys") + return len(names) +} + func cleanupRegistryKeys(*testing.T) { - // Clean up more entries to account for batching tests with many domains - cfg := ®istryConfigurator{nrptEntryCount: 20} + cfg := ®istryConfigurator{} _ = cfg.removeDNSMatchPolicies() } @@ -125,7 +181,7 @@ func TestNRPTDomainBatching(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -193,7 +249,7 @@ func TestNRPTDomainBatching(t *testing.T) { require.NoError(t, err) // Verify that exactly expectedRuleCount rules were created - assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount, + assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t), "Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount) // Verify all expected rules exist diff --git a/client/internal/dns/unclean_shutdown_windows.go b/client/internal/dns/unclean_shutdown_windows.go index 24a9eca50..ab0b2cc63 100644 --- a/client/internal/dns/unclean_shutdown_windows.go +++ b/client/internal/dns/unclean_shutdown_windows.go @@ -5,9 +5,8 @@ import ( ) type ShutdownState struct { - Guid string - GPO bool - NRPTEntryCount int + Guid string + GPO bool } func (s *ShutdownState) Name() string { @@ -16,9 +15,8 @@ func (s *ShutdownState) Name() string { func (s *ShutdownState) Cleanup() error { manager := ®istryConfigurator{ - guid: s.Guid, - gpo: s.GPO, - nrptEntryCount: s.NRPTEntryCount, + guid: s.Guid, + gpo: s.GPO, } if err := manager.restoreUncleanShutdownDNS(); err != nil { From 6210399e65ef6eaa64fbd20c3351f0c7235c2bd4 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:10:12 +0900 Subject: [PATCH 13/36] [client] Declare multi-buffer support for the loopback XDP program (#7230) --- client/internal/ebpf/ebpf/manager_linux.go | 47 ++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 7520a6387..64a3e5b54 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -2,17 +2,21 @@ package ebpf import ( _ "embed" + "fmt" "net" "sync" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/rlimit" log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" "github.com/netbirdio/netbird/client/internal/ebpf/manager" ) const ( + xdpProgName = "nb_xdp_prog" + mapKeyFeatures uint32 = 0 featureFlagWGProxy = 0b00000001 @@ -68,21 +72,50 @@ func (tf *GeneralManager) loadXdp() error { return err } - // load pre-compiled programs into the kernel. - err = loadBpfObjects(&tf.bpfObjs, nil) + // lo has no native XDP, so the program runs in generic mode. Unless it + // declares multi-buffer support the kernel must linearize every non-linear + // skb before running it. Loopback packets are up to 64 KB, so that is a + // contiguous GFP_ATOMIC allocation per packet, and when it fails the packet + // is dropped before the program runs, stalling local TCP connections. + // Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a + // plain attach when the kernel rejects it. + err = tf.attachXdp(iFace.Index, true) + if err == nil { + return nil + } + log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err) + + return tf.attachXdp(iFace.Index, false) +} + +func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error { + spec, err := loadBpf() if err != nil { - return err + return fmt.Errorf("load bpf spec: %w", err) + } + + if multiBuffer { + prog, ok := spec.Programs[xdpProgName] + if !ok { + return fmt.Errorf("program %s not found in bpf spec", xdpProgName) + } + prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS + } + + if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil { + return fmt.Errorf("load bpf objects: %w", err) } tf.link, err = link.AttachXDP(link.XDPOptions{ Program: tf.bpfObjs.NbXdpProg, - Interface: iFace.Index, + Interface: iFaceIndex, }) - if err != nil { - _ = tf.bpfObjs.Close() + if closeErr := tf.bpfObjs.Close(); closeErr != nil { + log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr) + } tf.link = nil - return err + return fmt.Errorf("attach xdp: %w", err) } return nil } From d5b283dca8be7586901240fa48407ae2a9acb0d4 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 19 Aug 2026 01:36:42 +0900 Subject: [PATCH 14/36] [management] Refuse a usage limit a one-off setup key cannot honour (#7220) refuse creating one-off keys without limits set to 1 --- .../handlers/setup_keys/setupkeys_handler.go | 13 +++++++ .../setup_keys/setupkeys_handler_test.go | 34 +++++++++++++++++++ .../setupkeys_handler_integration_test.go | 23 +++---------- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/management/server/http/handlers/setup_keys/setupkeys_handler.go b/management/server/http/handlers/setup_keys/setupkeys_handler.go index d267b6eea..bb498f46b 100644 --- a/management/server/http/handlers/setup_keys/setupkeys_handler.go +++ b/management/server/http/handlers/setup_keys/setupkeys_handler.go @@ -64,6 +64,19 @@ func (h *handler) createSetupKey(w http.ResponseWriter, r *http.Request) { return } + // A one-off key can be used once, and GenerateSetupKey pins its usage limit + // at 1 whatever the request says. Silently overriding a caller that asked + // for a different number leaves them holding a key that does not do what + // they configured, and no way to find out except by using it. Only values + // above 1 are refused: usage_limit is a required field with no null, so 0 + // cannot be told apart from a caller that has nothing to say about it. + if types.SetupKeyType(req.Type) == types.SetupKeyOneOff && req.UsageLimit > 1 { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, + "usage_limit %d is not valid for a one-off setup key, which can be used once; use type reusable for a key that can be used more than once", + req.UsageLimit), w) + return + } + expiresIn := time.Duration(req.ExpiresIn) * time.Second if expiresIn < 0 { diff --git a/management/server/http/handlers/setup_keys/setupkeys_handler_test.go b/management/server/http/handlers/setup_keys/setupkeys_handler_test.go index b137b6dd1..a9cfd4bd3 100644 --- a/management/server/http/handlers/setup_keys/setupkeys_handler_test.go +++ b/management/server/http/handlers/setup_keys/setupkeys_handler_test.go @@ -134,6 +134,40 @@ func TestSetupKeysHandlers(t *testing.T) { expectedBody: true, expectedSetupKey: expectedNewKey, }, + { + // A one-off key is used once. Asking for more used to be accepted + // and then quietly reduced to 1. + name: "Create One-Off Setup Key With Conflicting Usage Limit", + requestType: http.MethodPost, + requestPath: "/api/setup-keys", + requestBody: bytes.NewBuffer( + []byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + // 0 is what a caller sends when it has nothing to say about the + // usage limit, since the field is required and has no null, so it + // has to keep working. + name: "Create One-Off Setup Key Without Usage Limit", + requestType: http.MethodPost, + requestPath: "/api/setup-keys", + requestBody: bytes.NewBuffer( + []byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":0}", newSetupKeyName))), + expectedStatus: http.StatusOK, + expectedBody: false, + }, + { + // Only one-off keys are constrained; a reusable key means what it + // says. + name: "Create Reusable Setup Key With Usage Limit", + requestType: http.MethodPost, + requestPath: "/api/setup-keys", + requestBody: bytes.NewBuffer( + []byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"reusable\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))), + expectedStatus: http.StatusOK, + expectedBody: false, + }, { name: "Update Setup Key", requestType: http.MethodPut, diff --git a/management/server/http/testing/integration/setupkeys_handler_integration_test.go b/management/server/http/testing/integration/setupkeys_handler_integration_test.go index 0d3aaac82..21ad9d347 100644 --- a/management/server/http/testing/integration/setupkeys_handler_integration_test.go +++ b/management/server/http/testing/integration/setupkeys_handler_integration_test.go @@ -136,7 +136,10 @@ func Test_SetupKeys_Create(t *testing.T) { }, }, { - name: "Create Setup Key as on-off with more than one usage", + // The key used to be created anyway, with its usage limit quietly + // reduced to 1, so the caller was told a key they had not asked for + // was what they asked for. + name: "Create Setup Key as one-off with more than one usage", requestType: http.MethodPost, requestPath: "/api/setup-keys", requestBody: &api.CreateSetupKeyRequest{ @@ -146,23 +149,7 @@ func Test_SetupKeys_Create(t *testing.T) { Type: "one-off", UsageLimit: 3, }, - expectedStatus: http.StatusOK, - expectedResponse: &api.SetupKey{ - AutoGroups: []string{}, - Ephemeral: false, - Expires: time.Time{}, - Id: "", - Key: "", - LastUsed: time.Time{}, - Name: testing_tools.NewKeyName, - Revoked: false, - State: "valid", - Type: "one-off", - UpdatedAt: time.Now(), - UsageLimit: 1, - UsedTimes: 0, - Valid: true, - }, + expectedStatus: http.StatusUnprocessableEntity, }, { name: "Create Setup Key with expiration in the past", From ecfbd686b807f2e47295e1dfad3cd86b47af3c84 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 18 Aug 2026 16:49:13 +0000 Subject: [PATCH 15/36] [client, android] Expose ssh functionality for Android (#7156) Adds an SSHClient gomobile binding so the Android app can run an SSH session over the tunnel with a PTY, exposed through a listener interface for the in-app terminal. Server type is auto-detected from the SSH banner, which selects the auth path: JWT device-code flow, NetBird key, or a regular server (NetBird key first, then password). Host keys are verified against the peer registry for NetBird servers and trust-on-first-use for regular ones. --- client/android/login.go | 57 +- client/android/profile_prefs.go | 38 ++ client/android/ssh_client.go | 649 +++++++++++++++++++ client/android/ssh_known_hosts.go | 168 +++++ client/android/ssh_sessions.go | 104 +++ client/embed/embed.go | 9 +- client/internal/auth/auth.go | 27 +- client/internal/auth/oauth.go | 8 +- client/internal/profilemanager/prefs.go | 130 ++++ client/internal/profilemanager/prefs_test.go | 138 ++++ client/internal/profilemanager/service.go | 5 + client/ios/NetBirdSDK/login.go | 2 +- client/ssh/client/client.go | 14 +- client/ssh/client/terminal_unix.go | 34 +- client/ssh/client/terminal_windows.go | 32 +- client/ssh/common.go | 13 + client/ssh/handshake.go | 45 ++ client/ssh/proxy/proxy.go | 9 +- client/ssh/session.go | 84 +++ client/wasm/internal/ssh/client.go | 62 +- 20 files changed, 1470 insertions(+), 158 deletions(-) create mode 100644 client/android/profile_prefs.go create mode 100644 client/android/ssh_client.go create mode 100644 client/android/ssh_known_hosts.go create mode 100644 client/android/ssh_sessions.go create mode 100644 client/internal/profilemanager/prefs.go create mode 100644 client/internal/profilemanager/prefs_test.go create mode 100644 client/ssh/handshake.go create mode 100644 client/ssh/session.go diff --git a/client/android/login.go b/client/android/login.go index 897b1561e..24c911eb5 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -191,40 +191,49 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error { return nil } -// loginHintSetter is implemented by both concrete flows (PKCE and device code) -// but absent from the OAuthFlow interface, hence the assertion below — the same -// way internal/auth wires it in authenticateWithPKCEFlow. -type loginHintSetter interface { - SetLoginHint(hint string) -} - func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV) + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath)) if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } - // An empty hint is deliberate, not a fallback: a fresh profile leaves the - // choice to the IdP. Switching accounts is done by switching or removing - // profiles, not by logging out — logout keeps the email. - if a.cfgPath != "" { - if hint := readProfileEmail(a.cfgPath); hint != "" { - if setter, ok := oAuthFlow.(loginHintSetter); ok { - setter.SetLoginHint(hint) - } - } + return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil) +} + +// profileLoginHint returns the stored account email for the profile at cfgPath. +// An empty hint is deliberate, not a fallback: a fresh profile leaves the +// choice to the IdP. Switching accounts is done by switching or removing +// profiles, not by logging out — logout keeps the email. +func profileLoginHint(cfgPath string) string { + if cfgPath == "" { + return "" + } + return readProfileEmail(cfgPath) +} + +// runOAuthFlow drives an already acquired OAuth flow to a token: requests the +// flow info, presents the verification URL through the opener and waits for +// the browser round-trip. Open is called synchronously — it is what marks the +// surface as opened on the client side, and a fast token's OnLoginSuccess is +// a no-op until it has, so the dismissal would be dropped rather than +// delayed. Openers must therefore not block: they post their UI work and +// return. onWaiting, when set, runs after the URL is shown, right before the +// blocking wait. +func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) { + flowInfo, err := flow.RequestAuthInfo(ctx) + if err != nil { + return nil, fmt.Errorf("request auth info: %w", err) } - flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) - if err != nil { - return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) + urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) + + if onWaiting != nil { + onWaiting() } - go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) - - tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo) + tokenInfo, err := flow.WaitToken(ctx, flowInfo) if err != nil { - return nil, fmt.Errorf("waiting for browser login failed: %v", err) + return nil, fmt.Errorf("wait for token: %w", err) } return &tokenInfo, nil diff --git a/client/android/profile_prefs.go b/client/android/profile_prefs.go new file mode 100644 index 000000000..9c1fd307b --- /dev/null +++ b/client/android/profile_prefs.go @@ -0,0 +1,38 @@ +//go:build android + +package android + +import ( + "fmt" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +type prefsStore interface { + Get(namespace string, v any) (bool, error) + Put(namespace string, v any) error +} + +type profilePrefs struct { + prefs *profilemanager.Prefs +} + +func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) { + if configDir == "" || profileID == "" { + return nil, fmt.Errorf("profile prefs require a config dir and profile ID") + } + pm := NewProfileManager(configDir) + prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername) + if err != nil { + return nil, fmt.Errorf("resolve profile prefs: %w", err) + } + return &profilePrefs{prefs: prefs}, nil +} + +func (p *profilePrefs) Get(namespace string, v any) (bool, error) { + return p.prefs.Get(namespace, v) +} + +func (p *profilePrefs) Put(namespace string, v any) error { + return p.prefs.Put(namespace, v) +} diff --git a/client/android/ssh_client.go b/client/android/ssh_client.go new file mode 100644 index 000000000..2822b6539 --- /dev/null +++ b/client/android/ssh_client.go @@ -0,0 +1,649 @@ +//go:build android + +package android + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + gossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/profilemanager" + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/ssh/detection" +) + +const ( + sshDialTimeout = 30 * time.Second + sshDetectionTimeout = 5 * time.Second +) + +// PasswordRequiredMarker tells Java to prompt for a password and retry. It is +// a string because gomobile flattens errors to their message, so a sentinel +// value would not survive the binding. +const PasswordRequiredMarker = "netbird-ssh-password-required" + +// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation, +// retry with TrustHostKey set. The presented fingerprint is appended after the +// marker so the prompt can display it and the retry can guard against a key +// that changed between the two connects. Only regular (non-NetBird) servers +// reach this: NetBird peers verify against the registry. +const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown" + +var ( + errPasswordRequired = errors.New(PasswordRequiredMarker) + errClientClosed = errors.New("ssh client closed") +) + +// errHostKeyUnknown carries the presented fingerprint so Connect can build the +// marker message the Java side parses. +type errHostKeyUnknown struct { + fingerprint string +} + +func (e *errHostKeyUnknown) Error() string { + return HostKeyUnknownMarker + ":" + e.fingerprint +} + +// SSHTerminalListener receives SSH session events. It is implemented in Java. +// +// All callbacks are invoked from goroutines and may run concurrently with each +// other; the implementation must be safe to call from any thread. +type SSHTerminalListener interface { + OnConnected() + OnData(data []byte) + OnClose(reason string) + OnError(message string) +} + +// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile. +// +// It dials through the running NetBird tunnel and runs a standard SSH session +// on top with PTY enabled. Host-key verification uses the NetBird-provided +// peer SSH host keys, identical to the desktop client. +type SSHClient struct { + nb *Client + mu sync.Mutex + listener SSHTerminalListener + urlOpener URLOpener + + sshClient *gossh.Client + session *gossh.Session + stdin io.WriteCloser + closed bool + + // gen identifies the current connection attempt. Connect and Close bump it, + // so an in-flight dial or a reader left over from a previous connection + // finds itself stale and stays silent instead of publishing OnConnected or + // OnClose for a connection the caller already abandoned. + gen uint64 + dialCancel context.CancelFunc + + // knownHostsConfigDir and knownHostsProfile locate the TOFU store for + // regular SSH servers in the profile's preferences. Java supplies them, + // since an overlay IP is a different host under a different profile. Empty + // until set: without them a regular server cannot be verified and Connect + // refuses one. + knownHostsConfigDir string + knownHostsProfile string + // trustHostKey carries the fingerprint the user confirmed on a previous + // attempt, so the retry accepts exactly that key and persists it. + trustHostKey string +} + +// NewSSHClient creates a new SSH client bound to the running NetBird Client. +func NewSSHClient(c *Client) *SSHClient { + return &SSHClient{nb: c} +} + +// SetListener registers the Java listener. Must be called before Connect to +// receive any events. +func (s *SSHClient) SetListener(l SSHTerminalListener) { + s.mu.Lock() + s.listener = l + s.mu.Unlock() +} + +// SetURLOpener registers the Java URL opener used to display the device-code +// authorization page in a Custom Tabs window when the target peer requires +// JWT authentication. Must be set before Connect to be effective. +func (s *SSHClient) SetURLOpener(opener URLOpener) { + s.mu.Lock() + s.urlOpener = opener + s.mu.Unlock() +} + +// SetKnownHostsStore points the TOFU host-key store at a profile's preferences. +// Must be set before connecting to a regular SSH server; without it such a +// server cannot be verified and Connect refuses one. +func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) { + s.mu.Lock() + s.knownHostsConfigDir = configDir + s.knownHostsProfile = profileID + s.mu.Unlock() +} + +// TrustHostKey records the fingerprint the user confirmed for a regular server, +// so the next Connect accepts that exact key and adds it to the known-hosts +// store. Passing a fingerprint that no longer matches makes the connect fail +// rather than trust a key that changed since the prompt. +func (s *SSHClient) TrustHostKey(fingerprint string) { + s.mu.Lock() + s.trustHostKey = fingerprint + s.mu.Unlock() +} + +// Connect dials the SSH server through the NetBird tunnel and performs the +// SSH handshake. It auto-detects the server type via SSH banner inspection +// and selects the appropriate authentication path: +// +// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code +// flow, opens the verification URL through the registered URLOpener, and +// uses the resulting token as the SSH password. Host-key verification +// uses the NetBird peer registry. +// - NetBird-SSH server without JWT: authenticates with the NetBird SSH +// private key. Host-key verification uses the NetBird peer registry. +// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key +// first (so a user-installed NetBird public key works), then falls back +// to the supplied password if non-empty. Host-key verification is +// trust-on-first-use against the per-profile known-hosts store. +// +// The password parameter is only consulted for regular SSH servers. +func (s *SSHClient) Connect(host string, port int, user, password string) error { + if port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } + + cfg, cfgPath, cc := s.nb.authSnapshot() + if cc == nil { + return errors.New("netbird client not running") + } + if cfg == nil { + return errors.New("netbird config not loaded") + } + engine := cc.Engine() + if engine == nil { + return errors.New("netbird engine not available") + } + + s.mu.Lock() + s.gen++ + gen := s.gen + s.mu.Unlock() + + serverType := detectServerType(host, port) + log.Debugf("SSH server type: %s", serverType) + + authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password) + if err != nil { + return err + } + + clientConfig := &gossh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + Timeout: sshDialTimeout, + } + err = s.dialAndHandshake(gen, host, port, clientConfig) + + // An unknown host key is a prompt, not a failure: return the marker intact + // (rootCause would unwrap it) so Java can show the fingerprint and retry. + var unknownHost *errHostKeyUnknown + if errors.As(err, &unknownHost) { + return errors.New(unknownHost.Error()) + } + + // A regular server may still accept a password, so let the caller ask for + // one instead of failing. NetBird servers never use a password, so a + // failure there is genuine. + if err != nil && serverType != detection.ServerTypeNetBirdJWT && + serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) && + passwordCouldHelp(err, password != "") { + return errPasswordRequired + } + if err != nil { + return rootCause(err) + } + return nil +} + +// StartSession requests a PTY and starts an interactive shell. Output from +// the session is forwarded to the listener via OnData. +func (s *SSHClient) StartSession(cols, rows int) error { + err := s.startSession(cols, rows) + if err != nil { + log.Infof("SSH: start session failed: %v", err) + return rootCause(err) + } + return nil +} + +// Write sends data to the SSH session stdin. +func (s *SSHClient) Write(data []byte) error { + s.mu.Lock() + stdin := s.stdin + s.mu.Unlock() + if stdin == nil { + return errors.New("ssh session not started") + } + if _, err := stdin.Write(data); err != nil { + return fmt.Errorf("write stdin: %w", err) + } + return nil +} + +// Resize updates the PTY window size. +func (s *SSHClient) Resize(cols, rows int) error { + s.mu.Lock() + session := s.session + s.mu.Unlock() + if session == nil { + return errors.New("ssh session not started") + } + return session.WindowChange(rows, cols) +} + +// Reset makes a closed client usable for another Connect: Close leaves the +// one-shot guard set, and clearing it lets the same client back a reconnect. +func (s *SSHClient) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = false +} + +// Close terminates the SSH session and underlying connection. Safe to call +// multiple times. +func (s *SSHClient) Close() error { + s.mu.Lock() + s.gen++ + if s.dialCancel != nil { + s.dialCancel() + s.dialCancel = nil + } + sshClient := s.sshClient + session := s.session + stdin := s.stdin + s.sshClient = nil + s.session = nil + s.stdin = nil + notify := !s.closed + s.closed = true + listener := s.listener + s.mu.Unlock() + + if stdin != nil { + if err := stdin.Close(); err != nil { + log.Debugf("ssh: stdin close: %v", err) + } + } + if session != nil { + if err := session.Close(); err != nil && !errors.Is(err, io.EOF) { + log.Debugf("ssh: session close: %v", err) + } + } + var firstErr error + if sshClient != nil { + if err := sshClient.Close(); err != nil { + firstErr = err + } + } + if notify && listener != nil { + listener.OnClose("closed by client") + } + return firstErr +} + +func (s *SSHClient) startSession(cols, rows int) error { + log.Debugf("SSH: starting session %dx%d", cols, rows) + s.mu.Lock() + sshClient := s.sshClient + gen := s.gen + s.mu.Unlock() + + if sshClient == nil { + return errors.New("ssh client not connected") + } + + pty, err := nbssh.StartPTYSession(sshClient, cols, rows) + if err != nil { + return err + } + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + closeQuiet(pty.Session, "stale session") + return errClientClosed + } + s.session = pty.Session + s.stdin = pty.Stdin + s.mu.Unlock() + + readerDone := make(chan string, 2) + go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }() + go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }() + go func() { + reason := <-readerDone + if second := <-readerDone; reason == "" { + reason = second + } + s.notifyClose(gen, reason) + }() + log.Debug("SSH: session started, shell running") + return nil +} + +func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine, + serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) { + + switch serverType { + case detection.ServerTypeNetBirdJWT: + token, err := s.requestJWTToken(cfg, cfgPath) + if err != nil { + return nil, nil, fmt.Errorf("jwt: %w", err) + } + auths := []gossh.AuthMethod{gossh.Password(token)} + return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil + + case detection.ServerTypeNetBirdNoJWT: + if cfg.SSHKey == "" { + return nil, nil, errors.New("no NetBird SSH key available") + } + signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)) + if err != nil { + return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err) + } + auths := []gossh.AuthMethod{gossh.PublicKeys(signer)} + return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil + + case detection.ServerTypeRegular: + var auths []gossh.AuthMethod + if cfg.SSHKey != "" { + if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil { + auths = append(auths, gossh.PublicKeys(signer)) + } else { + log.Debugf("ssh: parse netbird key for regular auth: %v", err) + } + } + if password != "" { + pw := password + auths = append(auths, gossh.Password(pw)) + auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range questions { + answers[i] = pw + } + return answers, nil + })) + } + if len(auths) == 0 { + // Nothing to offer at all: ask for a password rather than failing, + // so the caller can retry once the user supplies one. + return nil, nil, errPasswordRequired + } + callback, err := s.tofuHostKeyCallback() + if err != nil { + return nil, nil, err + } + return auths, callback, nil + + default: + return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType) + } +} + +// tofuHostKeyCallback verifies a regular server's host key against the +// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so +// Java can show the fingerprint and, once confirmed, retry with the key +// trusted; a changed key is rejected outright, as OpenSSH does. When the user +// has confirmed a fingerprint, the callback accepts exactly that key and +// appends it to the store. +func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) { + s.mu.Lock() + configDir := s.knownHostsConfigDir + profileID := s.knownHostsProfile + trusted := s.trustHostKey + s.mu.Unlock() + + if configDir == "" || profileID == "" { + return nil, errors.New("no known-hosts store configured for regular SSH") + } + + store, err := openKnownHostsStore(configDir, profileID) + if err != nil { + return nil, fmt.Errorf("load known-hosts store: %w", err) + } + + return func(hostname string, remote net.Addr, key gossh.PublicKey) error { + verdict, err := store.verify(hostname, remote, key) + if err != nil { + return err + } + if verdict == hostKeyMatched { + return nil + } + if verdict == hostKeyChanged { + return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname) + } + + fingerprint := gossh.FingerprintSHA256(key) + if trusted == "" { + return &errHostKeyUnknown{fingerprint: fingerprint} + } + if trusted != fingerprint { + return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname) + } + if err := store.append(hostname, remote, key); err != nil { + return fmt.Errorf("persist trusted host key: %w", err) + } + // The confirmation is spent: now that the key is stored, a later + // reconnect must verify against the file, not re-accept this fingerprint. + s.mu.Lock() + s.trustHostKey = "" + s.mu.Unlock() + return nil + }, nil +} + +func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) { + s.mu.Lock() + urlOpener := s.urlOpener + s.mu.Unlock() + if urlOpener == nil { + return "", errors.New("URL opener not configured for JWT auth") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath)) + if err != nil { + return "", fmt.Errorf("create oauth flow: %w", err) + } + + // The status callback covers the browser round-trip, which would + // otherwise leave the terminal blank. + tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() { + s.notifyStatus("Waiting for browser authentication...") + }) + if err != nil { + return "", err + } + + token := tokenInfo.GetTokenToUse() + if token == "" { + return "", errors.New("empty token returned by IdP") + } + + // Tells the client the browser round-trip is over so it can dismiss the + // surface it opened, the same way the login and session-extend flows do. + // Without it the Custom Tab stays in front of the terminal even though the + // token has already been collected. + urlOpener.OnLoginSuccess() + + return token, nil +} + +func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout) + defer cancel() + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + return errClientClosed + } + s.dialCancel = cancel + s.mu.Unlock() + + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + + client, err := nbssh.Handshake(ctx, conn, addr, clientConfig) + if err != nil { + return err + } + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + closeQuiet(client, "stale ssh client") + return errClientClosed + } + s.sshClient = client + listener := s.listener + s.mu.Unlock() + + if listener != nil { + listener.OnConnected() + } + return nil +} + +func (s *SSHClient) readLoop(r io.Reader, name string) string { + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + s.mu.Lock() + listener := s.listener + s.mu.Unlock() + if listener != nil { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + listener.OnData(chunk) + } + } + if err != nil { + // EOF is a normal shell exit, so report it without a reason. + if errors.Is(err, io.EOF) { + return "" + } + log.Debugf("ssh %s read: %v", name, err) + return rootCause(err).Error() + } + } +} + +// notifyStatus writes a progress line to the terminal through the normal +// output path, so long steps are visible while nothing else is arriving. +func (s *SSHClient) notifyStatus(text string) { + s.mu.Lock() + listener := s.listener + s.mu.Unlock() + if listener != nil { + listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n")) + } +} + +func (s *SSHClient) notifyClose(gen uint64, reason string) { + s.mu.Lock() + if gen != s.gen || s.closed { + s.mu.Unlock() + return + } + s.closed = true + listener := s.listener + s.mu.Unlock() + if listener != nil { + listener.OnClose(reason) + } +} + +func closeQuiet(c io.Closer, label string) { + if c == nil { + return + } + if err := c.Close(); err != nil && !errors.Is(err, io.EOF) { + log.Debugf("ssh: close %s: %v", label, err) + } +} + +func detectServerType(host string, port int) detection.ServerType { + ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout) + defer cancel() + + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port) + if err != nil { + log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err) + return detection.ServerTypeRegular + } + return serverType +} + +// rootCause returns the innermost error of a %w chain, so the terminal shows +// "i/o timeout" rather than every layer that added context on the way up. +func rootCause(err error) error { + for { + // A joined error has no single root, so keep it as-is. + if _, ok := err.(interface{ Unwrap() []error }); ok { + return err + } + next := errors.Unwrap(err) + if next == nil { + return err + } + err = next + } +} + +// isAuthFailure distinguishes credential rejection from dial, timeout and +// host-key errors, which retrying with a password would not fix. +func isAuthFailure(err error) bool { + if errors.Is(err, errPasswordRequired) { + return true + } + var partial *gossh.PartialSuccessError + if errors.As(err, &partial) { + return true + } + return strings.Contains(err.Error(), "unable to authenticate") +} + +// passwordCouldHelp reports whether prompting for a password again can change +// the outcome. gossh lists a method under "attempted methods" only when the +// server offered it, so a supplied password that was never attempted means the +// server does not accept passwords and the real error should surface instead. +func passwordCouldHelp(err error, passwordOffered bool) bool { + if !passwordOffered { + return true + } + msg := err.Error() + return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive") +} diff --git a/client/android/ssh_known_hosts.go b/client/android/ssh_known_hosts.go new file mode 100644 index 000000000..eea90fd32 --- /dev/null +++ b/client/android/ssh_known_hosts.go @@ -0,0 +1,168 @@ +//go:build android + +package android + +import ( + "bytes" + "net" + "strconv" + "strings" + "sync" + + gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +const knownHostsNamespace = "ssh" + +const ( + hostKeyUnknown hostKeyVerdict = iota + hostKeyMatched + hostKeyChanged +) + +var knownHostsMu sync.Mutex + +type hostKeyVerdict uint8 + +type knownHostsSection struct { + KnownHosts []string `json:"knownHosts"` +} + +type knownHostsStore struct { + prefs prefsStore +} + +// RemoveKnownHost deletes every known-hosts entry for host:port from the +// profile's store, so a host trusted for a session that is being deleted does +// not linger. Java calls this only once no session targets that host, so a +// shared host stays trusted. A missing entry is not an error: the goal state +// is "absent". +func RemoveKnownHost(configDir, profileID, host string, port int) error { + store, err := openKnownHostsStore(configDir, profileID) + if err != nil { + return err + } + return store.removeHost(host, port) +} + +func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &knownHostsStore{prefs: prefs}, nil +} + +func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) { + lines, err := st.lines() + if err != nil { + return hostKeyUnknown, err + } + targets := knownHostsTargets(hostname, remote) + + verdict := hostKeyUnknown + for _, line := range lines { + pubKey, ok := knownHostsLineKey(line, targets) + if !ok { + continue + } + if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) { + return hostKeyMatched, nil + } + verdict = hostKeyChanged + } + return verdict, nil +} + +func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error { + line := knownhosts.Line(knownHostsTargets(hostname, remote), key) + + knownHostsMu.Lock() + defer knownHostsMu.Unlock() + + lines, err := st.lines() + if err != nil { + return err + } + return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)}) +} + +func (st *knownHostsStore) removeHost(host string, port int) error { + target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))) + + knownHostsMu.Lock() + defer knownHostsMu.Unlock() + + lines, err := st.lines() + if err != nil { + return err + } + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if knownHostsLineMatches(line, target) { + continue + } + kept = append(kept, line) + } + if len(kept) == len(lines) { + return nil + } + return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept}) +} + +func (st *knownHostsStore) lines() ([]string, error) { + var section knownHostsSection + if _, err := st.prefs.Get(knownHostsNamespace, §ion); err != nil { + return nil, err + } + return section.KnownHosts, nil +} + +func knownHostsTargets(hostname string, remote net.Addr) []string { + targets := []string{knownhosts.Normalize(hostname)} + if remote != nil { + if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] { + targets = append(targets, normalized) + } + } + return targets +} + +func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return nil, false + } + _, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed)) + if err != nil { + return nil, false + } + for _, host := range hosts { + for _, target := range targets { + if host == target { + return pubKey, true + } + } + } + return nil, false +} + +// knownHostsLineMatches reports whether a known-hosts line's address list +// contains the normalized target. Comment and blank lines never match. +func knownHostsLineMatches(line, target string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return false + } + fields := strings.Fields(trimmed) + if len(fields) == 0 { + return false + } + for _, addr := range strings.Split(fields[0], ",") { + if addr == target { + return true + } + } + return false +} diff --git a/client/android/ssh_sessions.go b/client/android/ssh_sessions.go new file mode 100644 index 000000000..44b5464e9 --- /dev/null +++ b/client/android/ssh_sessions.go @@ -0,0 +1,104 @@ +//go:build android + +package android + +const ( + sshSessionsNamespace = "ssh-sessions" + maxStoredSSHSessions = 50 +) + +type sshSessionRecord struct { + ID string `json:"id"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` +} + +type sshSessionsSection struct { + Sessions []sshSessionRecord `json:"sessions"` +} + +// SSHSessionEntry is one stored SSH session, without any credential. +type SSHSessionEntry struct { + ID string + Host string + Port int + User string +} + +// SSHSessionArray wraps stored SSH sessions for gomobile compatibility. +type SSHSessionArray struct { + items []*SSHSessionEntry +} + +// NewSSHSessionArray creates an empty session array to fill via Add. +func NewSSHSessionArray() *SSHSessionArray { + return &SSHSessionArray{} +} + +// Add appends a session entry, oldest first. +func (a *SSHSessionArray) Add(id, host string, port int, user string) { + a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user}) +} + +// Length returns the number of entries. +func (a *SSHSessionArray) Length() int { + return len(a.items) +} + +// Get returns the entry at index i, or nil when out of range. +func (a *SSHSessionArray) Get(i int) *SSHSessionEntry { + if i < 0 || i >= len(a.items) { + return nil + } + return a.items[i] +} + +// SSHSessionStore reads and writes a profile's stored SSH sessions. +type SSHSessionStore struct { + prefs prefsStore +} + +// NewSSHSessionStore opens the session store of the given profile. +func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &SSHSessionStore{prefs: prefs}, nil +} + +// Load returns the stored sessions, oldest first. +func (s *SSHSessionStore) Load() (*SSHSessionArray, error) { + var section sshSessionsSection + if _, err := s.prefs.Get(sshSessionsNamespace, §ion); err != nil { + return nil, err + } + + out := NewSSHSessionArray() + for _, record := range section.Sessions { + if record.ID == "" || record.Host == "" { + continue + } + out.Add(record.ID, record.Host, record.Port, record.User) + } + return out, nil +} + +// Save replaces the stored sessions, keeping only the newest entries when the +// list exceeds the storage cap. +func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error { + var items []*SSHSessionEntry + if sessions != nil { + items = sessions.items + } + if len(items) > maxStoredSSHSessions { + items = items[len(items)-maxStoredSSHSessions:] + } + + records := make([]sshSessionRecord, 0, len(items)) + for _, item := range items { + records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User}) + } + return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records}) +} diff --git a/client/embed/embed.go b/client/embed/embed.go index 99a6b8229..1b2d84d7e 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -21,7 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - sshcommon "github.com/netbirdio/netbird/client/ssh" + nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" @@ -521,12 +521,7 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error { return err } - storedKey, found := engine.GetPeerSSHKey(peerAddress) - if !found { - return sshcommon.ErrPeerNotFound - } - - return sshcommon.VerifyHostKey(storedKey, key, peerAddress) + return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key) } // SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index 153727a6c..b3a9e1158 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -138,26 +138,37 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) { // GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection // This avoids creating a new connection to the management server -func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) { +func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) { var flow OAuthFlow - var err error - err = a.withRetry(ctx, func(client *mgm.GrpcClient) error { + err := a.withRetry(ctx, func(client *mgm.GrpcClient) error { if forceDeviceAuth { - flow, err = a.getDeviceFlow(client) - return err + deviceFlow, err := a.getDeviceFlow(client) + if err != nil { + return err + } + deviceFlow.SetLoginHint(hint) + flow = deviceFlow + return nil } // Try PKCE flow first - flow, err = a.getPKCEFlow(client) + pkceFlow, err := a.getPKCEFlow(client) if err != nil { // If PKCE not supported, try Device flow if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { - flow, err = a.getDeviceFlow(client) - return err + deviceFlow, err := a.getDeviceFlow(client) + if err != nil { + return err + } + deviceFlow.SetLoginHint(hint) + flow = deviceFlow + return nil } return err } + pkceFlow.SetLoginHint(hint) + flow = pkceFlow return nil }) diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go index a50a2ce6f..91329c98b 100644 --- a/client/internal/auth/oauth.go +++ b/client/internal/auth/oauth.go @@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err) } - if hint != "" { - pkceFlowInfo.SetLoginHint(hint) - } + pkceFlowInfo.SetLoginHint(hint) return pkceFlowInfo, nil } @@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager. } } - if hint != "" { - deviceFlowInfo.SetLoginHint(hint) - } + deviceFlowInfo.SetLoginHint(hint) return deviceFlowInfo, nil } diff --git a/client/internal/profilemanager/prefs.go b/client/internal/profilemanager/prefs.go new file mode 100644 index 000000000..5613b0be3 --- /dev/null +++ b/client/internal/profilemanager/prefs.go @@ -0,0 +1,130 @@ +package profilemanager + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/netbirdio/netbird/util" +) + +const prefsFileSuffix = ".prefs.json" + +var prefsMu sync.Mutex + +// Prefs is a namespaced per-profile preference store backed by a single JSON +// file next to the profile config; it is deleted together with the profile. +type Prefs struct { + path string +} + +// ProfilePrefs returns the preference store of the profile identified by id. +func (s *ServiceManager) ProfilePrefs(id ID, username string) (*Prefs, error) { + if !IsValidProfileFilenameStem(id) { + return nil, fmt.Errorf("invalid profile ID: %q", id) + } + if id == defaultProfileName { + return &Prefs{path: filepath.Join(filepath.Dir(DefaultConfigPath), id.String()+prefsFileSuffix)}, nil + } + configDir, err := s.getConfigDir(username) + if err != nil { + return nil, fmt.Errorf("get config directory for user %s: %w", username, err) + } + return &Prefs{path: filepath.Join(configDir, id.String()+prefsFileSuffix)}, nil +} + +// Get unmarshals the namespace section into v and reports whether it exists. +func (p *Prefs) Get(namespace string, v any) (bool, error) { + if namespace == "" { + return false, fmt.Errorf("empty prefs namespace") + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return false, err + } + raw, ok := sections[namespace] + if !ok { + return false, nil + } + if err := json.Unmarshal(raw, v); err != nil { + return false, fmt.Errorf("decode prefs namespace %q: %w", namespace, err) + } + return true, nil +} + +// Put stores v as the namespace section, replacing any previous value. +func (p *Prefs) Put(namespace string, v any) error { + if namespace == "" { + return fmt.Errorf("empty prefs namespace") + } + raw, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("encode prefs namespace %q: %w", namespace, err) + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return err + } + sections[namespace] = raw + return writePrefsFile(p.path, sections) +} + +// Remove deletes the namespace section; a missing one is not an error. +func (p *Prefs) Remove(namespace string) error { + if namespace == "" { + return fmt.Errorf("empty prefs namespace") + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return err + } + if _, ok := sections[namespace]; !ok { + return nil + } + delete(sections, namespace) + return writePrefsFile(p.path, sections) +} + +func removePrefsFile(path string) error { + prefsMu.Lock() + defer prefsMu.Unlock() + return os.Remove(path) +} + +func readPrefsFile(path string) (map[string]json.RawMessage, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return map[string]json.RawMessage{}, nil + } + if err != nil { + return nil, fmt.Errorf("read prefs: %w", err) + } + + sections := map[string]json.RawMessage{} + if err := json.Unmarshal(data, §ions); err != nil { + return nil, fmt.Errorf("decode prefs: %w", err) + } + return sections, nil +} + +func writePrefsFile(path string, sections map[string]json.RawMessage) error { + if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, sections); err != nil { + return fmt.Errorf("write prefs: %w", err) + } + return nil +} diff --git a/client/internal/profilemanager/prefs_test.go b/client/internal/profilemanager/prefs_test.go new file mode 100644 index 000000000..692ade70f --- /dev/null +++ b/client/internal/profilemanager/prefs_test.go @@ -0,0 +1,138 @@ +package profilemanager + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testPrefsSection struct { + Mode uint8 `json:"mode"` + Dest string `json:"dest"` +} + +func TestProfilePrefs_RoundTrip(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2, Dest: "/tmp/x"})) + require.NoError(t, prefs.Put("other", map[string]int{"n": 1})) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, testPrefsSection{Mode: 2, Dest: "/tmp/x"}, got) + + var other map[string]int + found, err = prefs.Get("other", &other) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, map[string]int{"n": 1}, other) + }) +} + +func TestProfilePrefs_GetMissingNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.False(t, found) + }) +} + +func TestProfilePrefs_RemoveNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1})) + require.NoError(t, prefs.Put("other", map[string]int{"n": 1})) + require.NoError(t, prefs.Remove("filedrop")) + require.NoError(t, prefs.Remove("missing")) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.False(t, found) + + var other map[string]int + found, err = prefs.Get("other", &other) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, map[string]int{"n": 1}, other) + }) +} + +func TestProfilePrefs_RejectsInvalidID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.ProfilePrefs("../escape", username) + assert.Error(t, err) + }) +} + +func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + _, err = prefs.Get("", &testPrefsSection{}) + assert.Error(t, err) + assert.Error(t, prefs.Put("", testPrefsSection{})) + assert.Error(t, prefs.Remove("")) + }) +} + +func TestProfilePrefs_DefaultProfile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + prefs, err := sm.ProfilePrefs(defaultProfileName, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1})) + + expected := filepath.Join(filepath.Dir(DefaultConfigPath), "default"+prefsFileSuffix) + _, err = os.Stat(expected) + require.NoError(t, err) + }) +} + +func TestRemoveProfile_DeletesPrefsFile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2})) + + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + prefsPath := filepath.Join(configDir, created.ID.String()+prefsFileSuffix) + _, err = os.Stat(prefsPath) + require.NoError(t, err) + + require.NoError(t, sm.RemoveProfile(created.ID, username)) + _, err = os.Stat(prefsPath) + assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed") + }) +} diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index 696a60310..ec287f01a 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -420,6 +420,11 @@ func (s *ServiceManager) RemoveProfile(id ID, username string) error { log.Warnf("failed to remove profile state file %s: %v", stateFile, err) } + prefsFile := filepath.Join(filepath.Dir(target.Path), id.String()+prefsFileSuffix) + if err := removePrefsFile(prefsFile); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove profile prefs file %s: %v", prefsFile, err) + } + return nil } diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 6cba0c411..42a575359 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -323,7 +323,7 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin const authInfoRequestTimeout = 30 * time.Second func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth) + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "") if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index 4180849cd..31143a4f4 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -313,21 +313,23 @@ func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, er // dialSSH establishes an SSH connection without JWT authentication func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) { + if config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, config.Timeout) + defer cancel() + } + dialer := &net.Dialer{} conn, err := dialer.DialContext(ctx, network, addr) if err != nil { return nil, fmt.Errorf("dial %s: %w", addr, err) } - clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + client, err := nbssh.Handshake(ctx, conn, addr, config) if err != nil { - if closeErr := conn.Close(); closeErr != nil { - log.Debugf("connection close after handshake failure: %v", closeErr) - } - return nil, fmt.Errorf("ssh handshake: %w", err) + return nil, err } - client := ssh.NewClient(clientConn, chans, reqs) return &Client{ client: client, }, nil diff --git a/client/ssh/client/terminal_unix.go b/client/ssh/client/terminal_unix.go index aaa3418f9..a963dc8be 100644 --- a/client/ssh/client/terminal_unix.go +++ b/client/ssh/client/terminal_unix.go @@ -12,6 +12,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "golang.org/x/term" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error { @@ -82,37 +84,7 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error { return fmt.Errorf("get terminal size: %w", err) } - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - // Ctrl+C - ssh.VINTR: 3, - // Ctrl+\ - ssh.VQUIT: 28, - // Backspace - ssh.VERASE: 127, - // Ctrl+U - ssh.VKILL: 21, - // Ctrl+D - ssh.VEOF: 4, - ssh.VEOL: 0, - ssh.VEOL2: 0, - // Ctrl+Q - ssh.VSTART: 17, - // Ctrl+S - ssh.VSTOP: 19, - // Ctrl+Z - ssh.VSUSP: 26, - // Ctrl+O - ssh.VDISCARD: 15, - // Ctrl+R - ssh.VREPRINT: 18, - // Ctrl+W - ssh.VWERASE: 23, - // Ctrl+V - ssh.VLNEXT: 22, - } + modes := nbssh.DefaultTerminalModes terminal := os.Getenv("TERM") if terminal == "" { diff --git a/client/ssh/client/terminal_windows.go b/client/ssh/client/terminal_windows.go index 462438317..c6156fc26 100644 --- a/client/ssh/client/terminal_windows.go +++ b/client/ssh/client/terminal_windows.go @@ -10,6 +10,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) const ( @@ -80,28 +82,14 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro w, h := c.getWindowsConsoleSize() modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - ssh.ICRNL: 1, - ssh.OPOST: 1, - ssh.ONLCR: 1, - ssh.ISIG: 1, - ssh.ICANON: 1, - ssh.VINTR: 3, // Ctrl+C - ssh.VQUIT: 28, // Ctrl+\ - ssh.VERASE: 127, // Backspace - ssh.VKILL: 21, // Ctrl+U - ssh.VEOF: 4, // Ctrl+D - ssh.VEOL: 0, - ssh.VEOL2: 0, - ssh.VSTART: 17, // Ctrl+Q - ssh.VSTOP: 19, // Ctrl+S - ssh.VSUSP: 26, // Ctrl+Z - ssh.VDISCARD: 15, // Ctrl+O - ssh.VWERASE: 23, // Ctrl+W - ssh.VLNEXT: 22, // Ctrl+V - ssh.VREPRINT: 18, // Ctrl+R + ssh.ICRNL: 1, + ssh.OPOST: 1, + ssh.ONLCR: 1, + ssh.ISIG: 1, + ssh.ICANON: 1, + } + for mode, value := range nbssh.DefaultTerminalModes { + modes[mode] = value } if err := session.RequestPty("xterm-256color", h, w, modes); err != nil { diff --git a/client/ssh/common.go b/client/ssh/common.go index 3f4f3e9d1..4ebf8842a 100644 --- a/client/ssh/common.go +++ b/client/ssh/common.go @@ -35,6 +35,19 @@ type HostKeyVerifier interface { VerifySSHHostKey(peerAddress string, key []byte) error } +// PeerKeyLookup returns the stored SSH host key for a peer address. +type PeerKeyLookup func(peerAddress string) ([]byte, bool) + +// VerifySSHHostKey implements HostKeyVerifier by looking up the stored key +// and comparing it against the presented key. +func (l PeerKeyLookup) VerifySSHHostKey(peerAddress string, presentedKey []byte) error { + storedKey, found := l(peerAddress) + if !found { + return ErrPeerNotFound + } + return VerifyHostKey(storedKey, presentedKey, peerAddress) +} + // DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon type DaemonHostKeyVerifier struct { client proto.DaemonServiceClient diff --git a/client/ssh/handshake.go b/client/ssh/handshake.go new file mode 100644 index 000000000..e78a806be --- /dev/null +++ b/client/ssh/handshake.go @@ -0,0 +1,45 @@ +package ssh + +import ( + "context" + "fmt" + "io" + "net" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// Handshake runs the SSH client handshake on an already dialed conn and +// returns the resulting client. Dialing bounds only the TCP establishment; +// without a deadline on the socket a peer that accepts and then goes silent +// blocks the handshake forever, so the context deadline is applied to conn +// for the duration of the handshake. conn is closed on any error. +func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + closeHandshake(conn, "conn after deadline error") + return nil, fmt.Errorf("set handshake deadline: %w", err) + } + } + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + if err != nil { + closeHandshake(conn, "conn after handshake error") + return nil, fmt.Errorf("ssh handshake: %w", err) + } + + if err := conn.SetDeadline(time.Time{}); err != nil { + closeHandshake(sshConn, "ssh conn after deadline clear error") + return nil, fmt.Errorf("clear handshake deadline: %w", err) + } + + return ssh.NewClient(sshConn, chans, reqs), nil +} + +func closeHandshake(c io.Closer, label string) { + if err := c.Close(); err != nil { + log.Debugf("ssh: close %s: %v", label, err) + } +} diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go index 721810edb..070515b57 100644 --- a/client/ssh/proxy/proxy.go +++ b/client/ssh/proxy/proxy.go @@ -610,13 +610,10 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) return nil, fmt.Errorf("connect to server: %w", err) } - clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config) - if err != nil { - _ = conn.Close() - return nil, fmt.Errorf("SSH handshake: %w", err) - } + handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout) + defer cancel() - return cryptossh.NewClient(clientConn, chans, reqs), nil + return nbssh.Handshake(handshakeCtx, conn, addr, config) } func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error { diff --git a/client/ssh/session.go b/client/ssh/session.go new file mode 100644 index 000000000..999b6f251 --- /dev/null +++ b/client/ssh/session.go @@ -0,0 +1,84 @@ +package ssh + +import ( + "fmt" + "io" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// DefaultTerminalModes are the PTY modes used by the interactive terminal clients. +var DefaultTerminalModes = ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + ssh.VINTR: 3, // Ctrl+C + ssh.VQUIT: 28, // Ctrl+\ + ssh.VERASE: 127, // Backspace + ssh.VKILL: 21, // Ctrl+U + ssh.VEOF: 4, // Ctrl+D + ssh.VEOL: 0, + ssh.VEOL2: 0, + ssh.VSTART: 17, // Ctrl+Q + ssh.VSTOP: 19, // Ctrl+S + ssh.VSUSP: 26, // Ctrl+Z + ssh.VDISCARD: 15, // Ctrl+O + ssh.VREPRINT: 18, // Ctrl+R + ssh.VWERASE: 23, // Ctrl+W + ssh.VLNEXT: 22, // Ctrl+V +} + +// PTYSession is an interactive shell session with a PTY and its I/O pipes. +type PTYSession struct { + Session *ssh.Session + Stdin io.WriteCloser + Stdout io.Reader + Stderr io.Reader +} + +// StartPTYSession opens a session on the client, requests an xterm-256color PTY +// with the default terminal modes, wires up the I/O pipes and starts a shell. +// The session is closed on any error. +func StartPTYSession(client *ssh.Client, cols, rows int) (*PTYSession, error) { + session, err := client.NewSession() + if err != nil { + return nil, fmt.Errorf("new session: %w", err) + } + + pty, err := setupPTYSession(session, cols, rows) + if err != nil { + if closeErr := session.Close(); closeErr != nil { + log.Debugf("ssh: session close after setup error: %v", closeErr) + } + return nil, err + } + return pty, nil +} + +// setupPTYSession requests the PTY, opens the pipes and starts the shell on an +// already created session. +func setupPTYSession(session *ssh.Session, cols, rows int) (*PTYSession, error) { + if err := session.RequestPty("xterm-256color", rows, cols, DefaultTerminalModes); err != nil { + return nil, fmt.Errorf("request pty: %w", err) + } + + stdin, err := session.StdinPipe() + if err != nil { + return nil, fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := session.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := session.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe: %w", err) + } + + if err := session.Shell(); err != nil { + return nil, fmt.Errorf("start shell: %w", err) + } + + return &PTYSession{Session: session, Stdin: stdin, Stdout: stdout, Stderr: stderr}, nil +} diff --git a/client/wasm/internal/ssh/client.go b/client/wasm/internal/ssh/client.go index 9cfe65266..28ae95ec0 100644 --- a/client/wasm/internal/ssh/client.go +++ b/client/wasm/internal/ssh/client.go @@ -80,13 +80,12 @@ func (c *Client) Connect(host string, port int, username, jwtToken string, ipVer return fmt.Errorf("dial %s: %w", addr, err) } - sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + sshClient, err := nbssh.Handshake(ctx, conn, addr, config) if err != nil { - closeWithLog(conn, "connection after handshake error") - return fmt.Errorf("SSH handshake: %w", err) + return err } - c.sshClient = ssh.NewClient(sshConn, chans, reqs) + c.sshClient = sshClient logrus.Infof("SSH: Connected to %s", addr) return nil @@ -119,57 +118,26 @@ func (c *Client) getAuthMethods(jwtToken string) ([]ssh.AuthMethod, error) { return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil } -// StartSession starts an SSH session with PTY +// StartSession starts an SSH session with PTY. It holds the client lock for +// the whole startup so Close cannot tear the client down mid-setup and the +// new session cannot be installed into an already closed client. func (c *Client) StartSession(cols, rows int) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.sshClient == nil { return fmt.Errorf("SSH client not connected") } - session, err := c.sshClient.NewSession() + pty, err := nbssh.StartPTYSession(c.sshClient, cols, rows) if err != nil { - return fmt.Errorf("create session: %w", err) + return err } - c.mu.Lock() - defer c.mu.Unlock() - c.session = session - - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - ssh.VINTR: 3, - ssh.VQUIT: 28, - ssh.VERASE: 127, - } - - if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { - closeWithLog(session, "session after PTY error") - return fmt.Errorf("PTY request: %w", err) - } - - c.stdin, err = session.StdinPipe() - if err != nil { - closeWithLog(session, "session after stdin error") - return fmt.Errorf("get stdin: %w", err) - } - - c.stdout, err = session.StdoutPipe() - if err != nil { - closeWithLog(session, "session after stdout error") - return fmt.Errorf("get stdout: %w", err) - } - - c.stderr, err = session.StderrPipe() - if err != nil { - closeWithLog(session, "session after stderr error") - return fmt.Errorf("get stderr: %w", err) - } - - if err := session.Shell(); err != nil { - closeWithLog(session, "session after shell error") - return fmt.Errorf("start shell: %w", err) - } + c.session = pty.Session + c.stdin = pty.Stdin + c.stdout = pty.Stdout + c.stderr = pty.Stderr logrus.Info("SSH: Session started with PTY") return nil From 070a0a7bf1681890e2998b14fd1f9b1ee6d96444 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 19 Aug 2026 08:05:12 +0000 Subject: [PATCH 16/36] [client, android] Handle network changes without restarting the engine (#7144) On network changes the client restarted the whole engine. That is heavy-handed and slow: it tears down working state to recover from a transition the engine could handle itself. This replaces the restart with proper network event handling. Suspend the retry loops while no network is available. Instead of burning through backoff intervals against an unreachable network, the reconnection loops park until the OS reports a usable network again. Reconnect immediately on a network switch. When the OS hands us a new network, connections bound to the old one are swept and re-dialed right away, rather than waiting for a timeout to notice they are dead. --- client/android/client.go | 46 ++- client/android/connection_listener.go | 41 +++ client/grpc/dialer_generic.go | 51 ++-- client/grpc/dialer_js.go | 6 + client/grpc/retry.go | 49 ++++ client/grpc/retry_test.go | 91 ++++++ client/internal/connect.go | 63 ++++- client/internal/engine.go | 12 +- client/internal/peer/conn.go | 7 +- client/internal/peer/guard/guard.go | 42 ++- client/internal/peer/guard/guard_leak_test.go | 2 +- .../peer/guard/guard_netstate_test.go | 107 +++++++ client/internal/peer/listener.go | 29 ++ client/internal/peer/notifier.go | 111 ++++++-- .../internal/peer/notifier_concurrent_test.go | 108 +++++++ client/internal/peer/notifier_test.go | 27 +- client/internal/peer/status.go | 6 + client/ios/NetBirdSDK/client.go | 43 ++- client/ios/NetBirdSDK/connection_listener.go | 43 +++ client/netstate/netstate.go | 110 ++++++++ client/netstate/netstate_test.go | 170 +++++++++++ client/netsweep/netsweep.go | 267 ++++++++++++++++++ client/netsweep/netsweep_test.go | 241 ++++++++++++++++ client/netsweep/quick_retry.go | 39 +++ client/netsweep/quick_retry_test.go | 58 ++++ shared/management/client/grpc.go | 82 ++++-- shared/relay/client/client.go | 22 +- shared/relay/client/guard.go | 93 +++++- shared/relay/client/guard_test.go | 30 ++ shared/relay/client/manager.go | 19 +- shared/relay/client/picker.go | 3 + shared/signal/client/grpc.go | 86 ++++-- 32 files changed, 1965 insertions(+), 139 deletions(-) create mode 100644 client/android/connection_listener.go create mode 100644 client/grpc/retry.go create mode 100644 client/grpc/retry_test.go create mode 100644 client/internal/peer/guard/guard_netstate_test.go create mode 100644 client/internal/peer/notifier_concurrent_test.go create mode 100644 client/ios/NetBirdSDK/connection_listener.go create mode 100644 client/netstate/netstate.go create mode 100644 client/netstate/netstate_test.go create mode 100644 client/netsweep/netsweep.go create mode 100644 client/netsweep/netsweep_test.go create mode 100644 client/netsweep/quick_retry.go create mode 100644 client/netsweep/quick_retry_test.go create mode 100644 shared/relay/client/guard_test.go diff --git a/client/android/client.go b/client/android/client.go index a21348f27..71bbe4380 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -26,6 +26,8 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -40,11 +42,6 @@ const ( AnonymizeLevelStrict = nbAnonymize.LevelStrictString ) -// ConnectionListener export internal Listener for mobile -type ConnectionListener interface { - peer.Listener -} - // TunAdapter export internal TunAdapter for mobile type TunAdapter interface { device.TunAdapter @@ -85,6 +82,13 @@ type Client struct { deviceName string uiVersion string networkChangeListener listener.NetworkChangeListener + // netState outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run and RunWithoutLogin inject it into each new + // ConnectClient, which distributes it to every reconnection loop. + netState *netstate.State + + // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it. + sweeper *netsweep.Sweeper stateMu sync.RWMutex connectClient *internal.ConnectClient @@ -156,6 +160,8 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd recorder: peer.NewRecorder(""), ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, + netState: netstate.New(), + sweeper: netsweep.New(), } } @@ -196,7 +202,8 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid } // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) c.setState(cfg, cacheDir, cfgFile, connectClient) // This path runs the interactive SSO flow, so reaching here means the peer // is authenticated again — release the latch Status() reports from. Clear @@ -237,7 +244,8 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) c.setState(cfg, cacheDir, cfgFile, connectClient) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -285,6 +293,24 @@ func (c *Client) GetTunSettings() (*TunSettings, error) { }, nil } +// SetNetworkAvailable feeds OS-reported network availability into the client. +// While unavailable, the internal reconnect loops suspend their attempts and +// the connection listener reports NoNetwork instead of Connecting; when +// availability returns, the loops resume immediately with a fresh backoff. +func (c *Client) SetNetworkAvailable(available bool) { + c.netState.Set(available) + c.recorder.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +func (c *Client) NotifyNetworkChange() { + c.sweeper.MarkNetworkChange() + log.Infof("network change: connections marked stale") +} + // DebugBundle generates a debug bundle, uploads it, and returns the upload key. // It works both with and without a running engine. anonymizeLevel is "default" // or "strict"; strict also anonymizes internal IP ranges, peer names, and @@ -525,7 +551,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error { // SetConnectionListener set the network connection listener func (c *Client) SetConnectionListener(listener ConnectionListener) { - c.recorder.SetConnectionListener(listener) + if listener == nil { + c.recorder.RemoveConnectionListener() + return + } + c.recorder.SetConnectionListener(connectionListenerAdapter{listener}) } // RemoveConnectionListener remove connection listener diff --git a/client/android/connection_listener.go b/client/android/connection_listener.go new file mode 100644 index 000000000..77c47574b --- /dev/null +++ b/client/android/connection_listener.go @@ -0,0 +1,41 @@ +//go:build android + +package android + +import ( + "github.com/netbirdio/netbird/client/internal/peer" +) + +// Client state values delivered via ConnectionListener.OnStateChanged, +// re-exported as basic constants so gomobile emits them into the generated +// Java bindings. They mirror peer.ClientState*: append-only, never reorder. +const ( + ClientStateDisconnected = int(peer.ClientStateDisconnected) + ClientStateConnected = int(peer.ClientStateConnected) + ClientStateConnecting = int(peer.ClientStateConnecting) + ClientStateDisconnecting = int(peer.ClientStateDisconnecting) + ClientStateNoNetwork = int(peer.ClientStateNoNetwork) +) + +// ConnectionListener export internal Listener for mobile. It mirrors +// peer.Listener with OnStateChanged taking a plain int (one of the +// ClientState* constants), because gomobile cannot bind named types. +type ConnectionListener interface { + OnStateChanged(state int) + OnConnected() + OnDisconnected() + OnConnecting() + OnDisconnecting() + OnAddressChanged(string, string) + OnPeersListChanged(int) +} + +// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to +// peer.Listener, converting the typed state to the int the binding carries. +type connectionListenerAdapter struct { + ConnectionListener +} + +func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) { + a.ConnectionListener.OnStateChanged(int(state)) +} diff --git a/client/grpc/dialer_generic.go b/client/grpc/dialer_generic.go index 479575996..8a80525e9 100644 --- a/client/grpc/dialer_generic.go +++ b/client/grpc/dialer_generic.go @@ -16,28 +16,47 @@ import ( "google.golang.org/grpc" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netsweep" ) func WithCustomDialer(_ bool, _ string) grpc.DialOption { + return grpc.WithContextDialer(dialContext) +} + +// WithSweeper dials like WithCustomDialer but registers connections and +// dials with the sweeper. Append it after WithCustomDialer: gRPC applies +// dial options in order, so the later context dialer wins. +func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption { return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { - if runtime.GOOS == "linux" { - currentUser, err := user.Current() - if err != nil { - return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err) - } + dial := sweeper.StartDial(ctx) + defer dial.Release() - // the custom dialer requires root permissions which are not required for use cases run as non-root - if currentUser.Uid != "0" { - log.Debug("Not running as root, using standard dialer") - dialer := &net.Dialer{} - return dialer.DialContext(ctx, "tcp", addr) - } - } - - conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr) + conn, err := dialContext(dial.Ctx(), addr) if err != nil { - return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err) + return nil, err } - return conn, nil + return dial.WrapConn(conn) }) } + +func dialContext(ctx context.Context, addr string) (net.Conn, error) { + if runtime.GOOS == "linux" { + currentUser, err := user.Current() + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err) + } + + // the custom dialer requires root permissions which are not required for use cases run as non-root + if currentUser.Uid != "0" { + log.Debug("Not running as root, using standard dialer") + dialer := &net.Dialer{} + return dialer.DialContext(ctx, "tcp", addr) + } + } + + conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err) + } + return conn, nil +} diff --git a/client/grpc/dialer_js.go b/client/grpc/dialer_js.go index b89ec3c21..8863756d7 100644 --- a/client/grpc/dialer_js.go +++ b/client/grpc/dialer_js.go @@ -3,6 +3,7 @@ package grpc import ( "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/util/wsproxy/client" ) @@ -11,3 +12,8 @@ import ( func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption { return client.WithWebSocketDialer(tlsEnabled, component) } + +// WithSweeper is a no-op on WASM/JS: there is no network change signal. +func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption { + return grpc.EmptyDialOption{} +} diff --git a/client/grpc/retry.go b/client/grpc/retry.go new file mode 100644 index 000000000..754ffa341 --- /dev/null +++ b/client/grpc/retry.go @@ -0,0 +1,49 @@ +package grpc + +import ( + "context" + "errors" + "time" + + "github.com/cenkalti/backoff/v4" + + "github.com/netbirdio/netbird/client/netstate" +) + +// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on +// OS network availability transitions: an operation cut down by a network +// change retries the moment the network settles instead of sleeping through +// the recovery. A nil netState never fires, leaving plain backoff.Retry +// behavior. +func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error { + bo.Reset() + for { + err := operation() + if err == nil { + return nil + } + + var permanent *backoff.PermanentError + if errors.As(err, &permanent) { + return permanent.Err + } + + next := bo.NextBackOff() + if next == backoff.Stop { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + return err + } + + timer := time.NewTimer(next) + select { + case <-timer.C: + case <-netState.Changed(): + timer.Stop() + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } +} diff --git a/client/grpc/retry_test.go b/client/grpc/retry_test.go new file mode 100644 index 000000000..4edca47b6 --- /dev/null +++ b/client/grpc/retry_test.go @@ -0,0 +1,91 @@ +package grpc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/netstate" +) + +func TestRetryWakesOnNetworkChange(t *testing.T) { + ns := netstate.New() + attempts := 0 + operation := func() error { + attempts++ + if attempts == 1 { + return errors.New("cut by network change") + } + return nil + } + + go func() { + time.Sleep(20 * time.Millisecond) + ns.Set(false) + }() + + start := time.Now() + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns) + + require.NoError(t, err) + assert.Equal(t, 2, attempts, "network change must cause one immediate retry") + assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short") +} + +func TestRetryPermanentError(t *testing.T) { + sentinel := errors.New("permission denied") + operation := func() error { + return backoff.Permanent(sentinel) + } + + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil) + assert.ErrorIs(t, err, sentinel, "permanent errors must stop retries") +} + +func TestRetryNilNetState(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + if attempts < 3 { + return errors.New("transient") + } + return nil + } + + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil) + require.NoError(t, err) + assert.Equal(t, 3, attempts, "nil network state must preserve timed retries") +} + +func TestRetryStops(t *testing.T) { + failure := errors.New("still failing") + operation := func() error { + return failure + } + + err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil) + assert.ErrorIs(t, err, failure, "stop backoff must return the operation error") +} + +func TestRetryCtxCancelDuringSleep(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + operation := func() error { + return errors.New("failing") + } + + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New()) + + assert.ErrorIs(t, err, context.Canceled, "context cancellation must stop the retry loop") + assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep") +} diff --git a/client/internal/connect.go b/client/internal/connect.go index ceb39419e..e45ecca44 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -38,6 +38,8 @@ import ( "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ssh" sshconfig "github.com/netbirdio/netbird/client/ssh/config" @@ -70,18 +72,42 @@ type ConnectClient struct { updateManager *updater.Manager persistSyncResponse bool + + // netState gates every reconnection loop on OS-reported network + // availability. Nil (the default) disables gating; mobile platforms + // inject it via WithNetworkState. + netState *netstate.State + + // sweeper cuts the management, signal and relay connections on network + // change; nil disables it. + sweeper *netsweep.Sweeper +} + +// ConnectClientOption configures optional ConnectClient behavior. +type ConnectClientOption func(*ConnectClient) + +// WithNetworkState injects the OS network availability state that gates every +// reconnection loop; without it gating is disabled. +func WithNetworkState(netState *netstate.State) ConnectClientOption { + return func(c *ConnectClient) { c.netState = netState } +} + +// WithSweeper injects the network change sweeper. +func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption { + return func(c *ConnectClient) { c.sweeper = sweeper } } func NewConnectClient( ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, + opts ...ConnectClientOption, ) *ConnectClient { // Derive the run context here so Stop owns the cancel that unblocks the run // loop. runCancel is set once at construction, so Stop can call it without // racing the run loop's startup. Callers therefore need not cancel before Stop. runCtx, runCancel := context.WithCancel(ctx) - return &ConnectClient{ + c := &ConnectClient{ ctx: runCtx, runCancel: runCancel, runExited: make(chan struct{}), @@ -89,6 +115,10 @@ func NewConnectClient( statusRecorder: statusRecorder, engineMutex: sync.Mutex{}, } + for _, opt := range opts { + opt(c) + } + return c } func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { @@ -274,6 +304,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan return nil } + // suspend connection attempts while the OS reports no usable network + if waited, err := c.netState.Wait(c.ctx); err != nil { + return nil + } else if waited { + backOff.Reset() + } + state.Set(StatusConnecting) engineCtx, cancel := context.WithCancel(c.ctx) @@ -285,7 +322,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host) - mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled) + mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled, + mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper)) if err != nil { // On daemon shutdown / Down() the parent context is cancelled // and the dial fails with "context canceled". Wrapping that @@ -360,7 +398,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() // with the global Netbird config in hand connect (just a connection, no stream yet) Signal - signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey) + signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper) if err != nil { log.Error(err) return wrapErr(err) @@ -396,7 +434,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan engineConfig.StateDir = filepath.Dir(path) } - relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU) + relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU, + relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper)) c.statusRecorder.SetRelayMgr(relayManager) if len(relayURLs) > 0 { if token != nil { @@ -424,6 +463,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan UpdateManager: c.updateManager, ClientMetrics: c.clientMetrics, MetricsCtx: c.ctx, + NetState: c.netState, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine @@ -480,6 +520,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan // status stream stuck at Connecting. err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx)) if err != nil { + // Once the client context is cancelled backoff.WithContext surfaces the + // bare context error, and any attempt torn down mid-flight reports the + // same. That cancellation is the caller asking us to stop (Stop, Down or + // an engine restart), so exit cleanly instead of handing back a failure + // the caller would have to distinguish from a real one. + if c.ctx.Err() != nil && errors.Is(err, context.Canceled) { + log.Info("exiting client retry loop, context cancelled") + return nil + } + log.Debugf("exiting client retry loop due to unrecoverable error: %s", err) if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) { state.Set(StatusNeedsLogin) @@ -673,7 +723,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 { } // connectToSignal creates Signal Service client and established a connection -func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) { +func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) { var sigTLSEnabled bool if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS { sigTLSEnabled = true @@ -681,7 +731,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP sigTLSEnabled = false } - signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled) + signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled, + signal.WithNetworkState(netState), signal.WithSweeper(sweeper)) if err != nil { log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err) return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err) diff --git a/client/internal/engine.go b/client/internal/engine.go index d92c360f2..5380651a5 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -59,6 +59,7 @@ import ( "github.com/netbirdio/netbird/client/internal/syncstore" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/jobexec" + "github.com/netbirdio/netbird/client/netstate" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" @@ -181,6 +182,9 @@ type EngineServices struct { UpdateManager *updater.Manager ClientMetrics *metrics.ClientMetrics MetricsCtx context.Context + // NetState gates the reconnection loops on OS-reported network + // availability; nil disables gating. + NetState *netstate.State } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -204,6 +208,10 @@ type Engine struct { config *EngineConfig mobileDep MobileDependency + // netState gates the peer reconnection guards on OS-reported network + // availability; nil disables gating. + netState *netstate.State + // STUNs is a list of STUN servers used by ICE STUNs []*stun.URI // TURNs is a list of STUN servers used by ICE @@ -337,6 +345,7 @@ func NewEngine( syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, + netState: services.NetState, STUNs: []*stun.URI{}, TURNs: []*stun.URI{}, networkSerial: 0, @@ -1893,7 +1902,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV Addr: e.getRosenpassAddr(), PermissiveMode: e.config.RosenpassPermissive, }, - ICEConfig: e.createICEConfig(), + ICEConfig: e.createICEConfig(), + NetworkState: e.netState, } serviceDependencies := peer.ServiceDependencies{ diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index f3235ec7f..a3c320027 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -26,6 +26,7 @@ import ( "github.com/netbirdio/netbird/client/internal/portforward" "github.com/netbirdio/netbird/client/internal/rosenpass" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" ) @@ -93,6 +94,10 @@ type ConnConfig struct { // ICEConfig ICE protocol configuration ICEConfig icemaker.Config + + // NetworkState gates the reconnection guard on OS-reported network + // availability; nil disables gating. + NetworkState *netstate.State } type Conn struct { @@ -254,7 +259,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error { conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer) } - conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher) + conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState) conn.wg.Add(1) go func() { diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 6c2e846a9..68d77d318 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -6,6 +6,8 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netstate" ) // ConnStatus represents the connection state as seen by the guard. @@ -31,20 +33,26 @@ type connStatusFunc func() ConnStatus // - Relayed connection disconnected // - ICE candidate changes type Guard struct { - log *log.Entry - isConnectedOnAllWay connStatusFunc - timeout time.Duration - srWatcher *SRWatcher + log *log.Entry + isConnectedOnAllWay connStatusFunc + timeout time.Duration + srWatcher *SRWatcher + // netState gates reconnect attempts on OS-reported network availability; + // nil disables gating. + netState *netstate.State relayedConnDisconnected chan struct{} iCEConnDisconnected chan struct{} } -func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard { +// NewGuard creates a reconnection guard for a peer connection. A nil netState +// disables network availability gating. +func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard { return &Guard{ log: log, isConnectedOnAllWay: isConnectedFn, timeout: timeout, srWatcher: srWatcher, + netState: netState, relayedConnDisconnected: make(chan struct{}, 1), iCEConnDisconnected: make(chan struct{}, 1), } @@ -96,9 +104,16 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { iceState := &iceRetryState{log: g.log} defer iceState.reset() + netChanged := g.netState.Changed() + for { select { case <-tickerChannel: + // skip attempts while the OS reports no usable network; the + // netChanged case below resumes the loop once it returns + if !g.netState.IsOnline() { + continue + } switch g.isConnectedOnAllWay() { case ConnStatusConnected: // all good, nothing to do @@ -135,6 +150,23 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { tickerChannel = ticker.C iceState.reset() + case <-netChanged: + // Re-arm for the next transition before acting on this one. + netChanged = g.netState.Changed() + if !g.netState.IsOnline() { + continue + } + // Ticks skipped while offline drove the backoff towards its + // maximum without ever attempting, and left the ICE budget + // frozen — possibly in hourly mode. Recover on our own so the + // peer does not depend on a signal or relay event that never + // comes when both stayed up across the outage. + g.log.Debugf("network is back, reset reconnection ticker") + ticker.Stop() + ticker = g.newReconnectTicker(ctx) + tickerChannel = ticker.C + iceState.reset() + case <-ctx.Done(): g.log.Debugf("context is done, stop reconnect loop") return diff --git a/client/internal/peer/guard/guard_leak_test.go b/client/internal/peer/guard/guard_leak_test.go index ded3e4aea..3d82ec591 100644 --- a/client/internal/peer/guard/guard_leak_test.go +++ b/client/internal/peer/guard/guard_leak_test.go @@ -15,7 +15,7 @@ import ( func newTestGuard(status connStatusFunc) *Guard { srw := NewSRWatcher(nil, nil, nil, ice.Config{}) - return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw) + return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil) } // countBackoffTickerGoroutines returns how many goroutines are currently sitting diff --git a/client/internal/peer/guard/guard_netstate_test.go b/client/internal/peer/guard/guard_netstate_test.go new file mode 100644 index 000000000..2ab736428 --- /dev/null +++ b/client/internal/peer/guard/guard_netstate_test.go @@ -0,0 +1,107 @@ +package guard + +import ( + "context" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer/ice" + "github.com/netbirdio/netbird/client/netstate" +) + +// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the +// backoff must be able to grow well past the outage, as it does in production +// where the timeout is seconds to minutes. +func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard { + srw := NewSRWatcher(nil, nil, nil, ice.Config{}) + return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState) +} + +// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected +// across a network outage while neither signal nor relay reports an event — +// both stayed up, as on a short airplane mode toggle over Wi-Fi. +// +// Every tick taken while offline is skipped, but it still advances the +// exponential backoff, so by the time the network returns the next tick can be +// tens of seconds away. Without an explicit reaction to the transition the +// peer waits out that interval for a recovery that could start immediately. +func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Start from the reconnect ticker (800ms initial interval), the state a + // peer is in after it loses its connection. + go g.Start(ctx, func() { attempts.Add(1) }) + g.SetRelayedConnDisconnected() + + // Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped + // while offline, but each one doubles the wait for the next. + netState.Set(false) + time.Sleep(8 * time.Second) + + offlineAttempts := attempts.Load() + if offlineAttempts != 0 { + t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts) + } + + netState.Set(true) + + // The next organic tick is now several seconds out, so anything within + // this window can only come from reacting to the transition itself. + pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second) + defer stopPolling() + + select { + case <-pollCtx.Done(): + t.Fatal("peer was not retried within 2s of the network coming back, " + + "with neither a signal nor a relay event to fall back on") + case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }): + } +} + +// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going +// offline must not itself trigger an attempt. +func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Start(ctx, func() { attempts.Add(1) }) + + netState.Set(false) + time.Sleep(5 * time.Second) + + if got := attempts.Load(); got != 0 { + t.Fatalf("callback ran %d times after going offline, want 0", got) + } +} + +// pollUntil closes the returned channel once cond holds. It gives up when ctx +// is done, so the polling goroutine never outlives the test that started it. +func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} { + done := make(chan struct{}) + go func() { + for { + if cond() { + close(done) + return + } + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Millisecond): + } + } + }() + return done +} diff --git a/client/internal/peer/listener.go b/client/internal/peer/listener.go index c601fe534..2bb7fcf32 100644 --- a/client/internal/peer/listener.go +++ b/client/internal/peer/listener.go @@ -1,11 +1,40 @@ package peer +// ClientState identifies the client connection state delivered via +// Listener.OnStateChanged. +type ClientState int + +// Client states. The numeric values cross the gomobile boundary (the mobile +// bindings re-export them as integer constants), so they are a wire format: +// append new states at the end, never reorder or insert. +const ( + ClientStateDisconnected ClientState = iota + ClientStateConnected + ClientStateConnecting + ClientStateDisconnecting + // ClientStateNoNetwork is an overlay state: it is never stored as the + // last notification, only derived from ClientStateConnecting while the + // OS reports no usable network (see notifier.effectiveState). + ClientStateNoNetwork +) + // Listener is a callback type about the NetBird network connection state type Listener interface { + // OnStateChanged reports every client state transition. New states are + // delivered only through this callback; the per-state callbacks below + // are kept for compatibility and will be removed once all consumers + // have migrated. + OnStateChanged(state ClientState) + + // Deprecated: consume OnStateChanged instead. OnConnected() + // Deprecated: consume OnStateChanged instead. OnDisconnected() + // Deprecated: consume OnStateChanged instead. OnConnecting() + // Deprecated: consume OnStateChanged instead. OnDisconnecting() + OnAddressChanged(string, string) OnPeersListChanged(int) } diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 8d1954fe5..1ee1d32ea 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -4,31 +4,64 @@ import ( "sync" ) -const ( - stateDisconnected = iota - stateConnected - stateConnecting - stateDisconnecting -) - type notifier struct { + // publishLock orders state publication: it is held across computing the + // effective state and handing it to the listener, so a transition cannot + // overtake a newer one and leave the listener on a stale state. + publishLock sync.Mutex serverStateLock sync.Mutex listenersLock sync.Mutex listener Listener currentClientState bool - lastNotification int + lastNotification ClientState lastNumberOfPeers int lastFqdnAddress string lastIPAddress string + networkAvailable bool } func newNotifier() *notifier { - return ¬ifier{} + return ¬ifier{ + networkAvailable: true, + } +} + +// effectiveState maps the computed state to what listeners should see: +// while the OS reports no usable network, "Connecting" would be a lie — +// connection attempts are suspended — so it is reported as NoNetwork. +// Caller must hold serverStateLock. +func (n *notifier) effectiveState(state ClientState) ClientState { + if !n.networkAvailable && state == ClientStateConnecting { + return ClientStateNoNetwork + } + return state +} + +// setNetworkAvailable records the OS network availability and re-notifies +// the listener when the flag flips the effective state (Connecting <-> +// NoNetwork). +func (n *notifier) setNetworkAvailable(available bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + + n.serverStateLock.Lock() + if n.networkAvailable == available { + n.serverStateLock.Unlock() + return + } + previous := n.effectiveState(n.lastNotification) + n.networkAvailable = available + current := n.effectiveState(n.lastNotification) + n.serverStateLock.Unlock() + + if previous != current { + n.notify(current) + } } func (n *notifier) setListener(listener Listener) { n.serverStateLock.Lock() - lastNotification := n.lastNotification + lastNotification := n.effectiveState(n.lastNotification) numOfPeers := n.lastNumberOfPeers fqdnAddress := n.lastFqdnAddress address := n.lastIPAddress @@ -52,6 +85,9 @@ func (n *notifier) removeListener() { } func (n *notifier) updateServerStates(mgmState bool, signalState bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() calculatedState := n.calculateState(mgmState, signalState) @@ -61,43 +97,54 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { } n.lastNotification = calculatedState + effective := n.effectiveState(calculatedState) n.serverStateLock.Unlock() - n.notify(calculatedState) + n.notify(effective) } func (n *notifier) clientStart() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = true - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting + effective := n.effectiveState(ClientStateConnecting) n.serverStateLock.Unlock() - n.notify(stateConnecting) + n.notify(effective) } func (n *notifier) clientStop() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnected + n.lastNotification = ClientStateDisconnected n.serverStateLock.Unlock() - n.notify(stateDisconnected) + n.notify(ClientStateDisconnected) } func (n *notifier) clientTearDown() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnecting + n.lastNotification = ClientStateDisconnecting n.serverStateLock.Unlock() - n.notify(stateDisconnecting) + n.notify(ClientStateDisconnecting) } -func (n *notifier) isServerStateChanged(newState int) bool { +func (n *notifier) isServerStateChanged(newState ClientState) bool { return n.lastNotification != newState } -func (n *notifier) notify(state int) { +func (n *notifier) notify(state ClientState) { n.listenersLock.Lock() listener := n.listener n.listenersLock.Unlock() @@ -109,20 +156,20 @@ func (n *notifier) notify(state int) { notifyListener(listener, state) } -func (n *notifier) calculateState(managementConn, signalConn bool) int { +func (n *notifier) calculateState(managementConn, signalConn bool) ClientState { if managementConn && signalConn { - return stateConnected + return ClientStateConnected } if !managementConn && !signalConn && !n.currentClientState { - return stateDisconnected + return ClientStateDisconnected } - if n.lastNotification == stateDisconnecting { - return stateDisconnecting + if n.lastNotification == ClientStateDisconnecting { + return ClientStateDisconnecting } - return stateConnecting + return ClientStateConnecting } func (n *notifier) peerListChanged(numOfPeers int) { @@ -159,15 +206,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) { listener.OnAddressChanged(fqdn, address) } -func notifyListener(l Listener, state int) { +func notifyListener(l Listener, state ClientState) { + // legacy per-state callbacks; NoNetwork is delivered only via + // OnStateChanged below switch state { - case stateDisconnected: + case ClientStateDisconnected: l.OnDisconnected() - case stateConnected: + case ClientStateConnected: l.OnConnected() - case stateConnecting: + case ClientStateConnecting: l.OnConnecting() - case stateDisconnecting: + case ClientStateDisconnecting: l.OnDisconnecting() } + + l.OnStateChanged(state) } diff --git a/client/internal/peer/notifier_concurrent_test.go b/client/internal/peer/notifier_concurrent_test.go new file mode 100644 index 000000000..fcaaaad3b --- /dev/null +++ b/client/internal/peer/notifier_concurrent_test.go @@ -0,0 +1,108 @@ +package peer + +import ( + "sync" + "testing" + "time" +) + +type recordingListener struct { + mu sync.Mutex + states []ClientState + onState func(ClientState) +} + +func (l *recordingListener) OnStateChanged(state ClientState) { + l.mu.Lock() + l.states = append(l.states, state) + hook := l.onState + l.mu.Unlock() + + if hook != nil { + hook(state) + } +} + +func (l *recordingListener) last() (ClientState, bool) { + l.mu.Lock() + defer l.mu.Unlock() + if len(l.states) == 0 { + return 0, false + } + return l.states[len(l.states)-1], true +} + +func (l *recordingListener) snapshot() []ClientState { + l.mu.Lock() + defer l.mu.Unlock() + return append([]ClientState(nil), l.states...) +} + +func (l *recordingListener) OnConnected() {} +func (l *recordingListener) OnDisconnected() {} +func (l *recordingListener) OnConnecting() {} +func (l *recordingListener) OnDisconnecting() {} +func (l *recordingListener) OnAddressChanged(string, string) {} +func (l *recordingListener) OnPeersListChanged(int) {} + +// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first +// transition inside the listener callback and flips availability again from +// another goroutine while it is parked. The second flip must not publish +// ahead of the one in flight, otherwise the listener ends up on a state the +// notifier already superseded. +func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) { + n := newNotifier() + n.currentClientState = true + n.lastNotification = ClientStateConnecting + + entered := make(chan struct{}) + release := make(chan struct{}) + + l := &recordingListener{} + l.onState = func(state ClientState) { + if state != ClientStateNoNetwork { + return + } + l.mu.Lock() + l.onState = nil + l.mu.Unlock() + close(entered) + <-release + } + n.listener = l + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + n.setNetworkAvailable(false) + }() + + <-entered + + flipped := make(chan struct{}) + go func() { + defer close(flipped) + n.setNetworkAvailable(true) + }() + + select { + case <-flipped: + t.Fatal("the online transition published while the offline one was " + + "still in flight; publication is not serialized") + case <-time.After(200 * time.Millisecond): + } + + close(release) + <-flipped + wg.Wait() + + got, ok := l.last() + if !ok { + t.Fatal("listener never observed a state") + } + if got != ClientStateConnecting { + t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v", + got, l.snapshot()) + } +} diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index 0b7722b0c..a73016b05 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -6,29 +6,32 @@ import ( ) type mocListener struct { - lastState int + lastState ClientState wg sync.WaitGroup peersWg sync.WaitGroup peers int } func (l *mocListener) OnConnected() { - l.lastState = stateConnected + l.lastState = ClientStateConnected l.wg.Done() } func (l *mocListener) OnDisconnected() { - l.lastState = stateDisconnected + l.lastState = ClientStateDisconnected l.wg.Done() } func (l *mocListener) OnConnecting() { - l.lastState = stateConnecting + l.lastState = ClientStateConnecting l.wg.Done() } func (l *mocListener) OnDisconnecting() { - l.lastState = stateDisconnecting + l.lastState = ClientStateDisconnecting l.wg.Done() } +func (l *mocListener) OnStateChanged(state ClientState) { + +} func (l *mocListener) OnAddressChanged(host, addr string) { } @@ -57,15 +60,15 @@ func Test_notifier_serverState(t *testing.T) { type scenario struct { name string - expected int + expected ClientState mgmState bool signalState bool } scenarios := []scenario{ - {"connected", stateConnected, true, true}, - {"mgm down", stateConnecting, false, true}, - {"signal down", stateConnecting, true, false}, - {"disconnected", stateDisconnected, false, false}, + {"connected", ClientStateConnected, true, true}, + {"mgm down", ClientStateConnecting, false, true}, + {"signal down", ClientStateConnecting, true, false}, + {"disconnected", ClientStateDisconnected, false, false}, } for _, tt := range scenarios { @@ -85,7 +88,7 @@ func Test_notifier_SetListener(t *testing.T) { listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) listener.wait() listener.waitPeers() @@ -99,7 +102,7 @@ func Test_notifier_RemoveListener(t *testing.T) { listener.setWaiter() listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) // setListener replays cached state on a goroutine; wait for both the state // and peers callbacks to finish so we don't race on listener.peers. diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 423ce9b23..24e3e7fac 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1211,6 +1211,12 @@ func (d *Status) ClientTeardown() { d.notifyStateChange() } +// SetNetworkAvailable records the OS-reported network availability; while +// unavailable, listeners see NoNetwork instead of Connecting. +func (d *Status) SetNetworkAvailable(available bool) { + d.notifier.setNetworkAvailable(available) +} + // SetConnectionListener set a listener to the notifier func (d *Status) SetConnectionListener(listener Listener) { d.notifier.setListener(listener) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 5df6f92f1..f92f085ab 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -22,6 +22,8 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -36,11 +38,6 @@ const ( AnonymizeLevelStrict = nbAnonymize.LevelStrictString ) -// ConnectionListener export internal Listener for mobile -type ConnectionListener interface { - peer.Listener -} - // RouteListener export internal RouteListener for mobile type NetworkChangeListener interface { listener.NetworkChangeListener @@ -87,6 +84,12 @@ type Client struct { onHostDnsFn func([]string) dnsManager dns.IosDnsManager loginComplete bool + // netState outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run injects it into each new ConnectClient, which + // distributes it to every reconnection loop. + netState *netstate.State + // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it. + sweeper *netsweep.Sweeper // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config @@ -109,6 +112,8 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, dnsManager: dnsManager, + netState: netstate.New(), + sweeper: netsweep.New(), } } @@ -184,7 +189,8 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { c.onHostDnsFn = func([]string) {} cfg.WgIface = interfaceName - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) c.setState(cfg, connectClient) // Persist the latest sync response so DebugBundle can include the network // map. On iOS this is backed by disk to keep it out of the constrained @@ -193,6 +199,25 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath) } +// SetNetworkAvailable feeds OS-reported network availability into the client +// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops +// suspend their attempts and the connection listener reports NoNetwork +// instead of Connecting; when availability returns, the loops resume +// immediately with a fresh backoff. +func (c *Client) SetNetworkAvailable(available bool) { + c.netState.Set(available) + c.recorder.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +func (c *Client) NotifyNetworkChange() { + c.sweeper.MarkNetworkChange() + log.Infof("network change: connections marked stale") +} + // Stop the internal client and free the resources func (c *Client) Stop() { c.ctxCancelLock.Lock() @@ -331,7 +356,11 @@ func (c *Client) GetStatusDetails() *StatusDetails { // SetConnectionListener set the network connection listener func (c *Client) SetConnectionListener(listener ConnectionListener) { - c.recorder.SetConnectionListener(listener) + if listener == nil { + c.recorder.RemoveConnectionListener() + return + } + c.recorder.SetConnectionListener(connectionListenerAdapter{listener}) } // RemoveConnectionListener remove connection listener diff --git a/client/ios/NetBirdSDK/connection_listener.go b/client/ios/NetBirdSDK/connection_listener.go new file mode 100644 index 000000000..d792537ba --- /dev/null +++ b/client/ios/NetBirdSDK/connection_listener.go @@ -0,0 +1,43 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/peer" +) + +// Client state values, re-exported as basic constants so gomobile emits them +// into the generated bindings. They mirror peer.ClientState*: append-only, +// never reorder. +const ( + ClientStateDisconnected = int(peer.ClientStateDisconnected) + ClientStateConnected = int(peer.ClientStateConnected) + ClientStateConnecting = int(peer.ClientStateConnecting) + ClientStateDisconnecting = int(peer.ClientStateDisconnecting) + ClientStateNoNetwork = int(peer.ClientStateNoNetwork) +) + +// ConnectionListener export internal Listener for mobile. +// +// It intentionally lacks OnStateChanged for now: adding a method to a gomobile +// interface breaks every Swift implementation, so the iOS app keeps building +// against the legacy per-state callbacks. A follow-up will extend it together +// with the app. +type ConnectionListener interface { + OnConnected() + OnDisconnected() + OnConnecting() + OnDisconnecting() + OnAddressChanged(string, string) + OnPeersListChanged(int) +} + +// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to +// peer.Listener. +type connectionListenerAdapter struct { + ConnectionListener +} + +// OnStateChanged is dropped on iOS until the app adopts the state callback; +// the legacy per-state callbacks continue to fire. +func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {} diff --git a/client/netstate/netstate.go b/client/netstate/netstate.go new file mode 100644 index 000000000..0d7a1268b --- /dev/null +++ b/client/netstate/netstate.go @@ -0,0 +1,110 @@ +// Package netstate tracks OS-reported network availability for the client. +// +// A State instance is owned by the platform integration (e.g. the Android or +// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and +// is injected into the connection retry loops (management, signal, relay, +// peer guards and the top-level connect loop), which consult it to avoid +// burning CPU and battery on reconnect attempts while the device has no +// network at all (e.g. airplane mode), and to reset their backoff as soon as +// the network returns. +// +// Consumers hold a *State that may be nil — every non-mobile platform leaves +// it unset. The read methods are safe on a nil receiver: they report online +// and never block, so consumers behave as if this package did not exist. +package netstate + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" +) + +// State holds the OS-reported network availability. The zero value is not +// usable; create instances with New. +type State struct { + mu sync.Mutex + online bool + changed chan struct{} +} + +// New creates a State that starts online. Platforms without network tracking +// pass a nil *State instead: the read methods treat nil as always online and +// never block, so consumers need no nil guards. +func New() *State { + return &State{ + online: true, + changed: make(chan struct{}), + } +} + +// Set records whether the OS reports any usable network. Transitions wake up +// all Wait callers immediately. Unlike the read methods, Set is not nil-safe: +// it is only for the platform owner that created the State with New. +func (s *State) Set(online bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.online == online { + return + } + s.online = online + close(s.changed) + s.changed = make(chan struct{}) + log.Infof("OS network availability changed: online=%t", online) +} + +// IsOnline reports whether the OS reports at least one usable network. On a +// nil receiver — no State injected — it reports online. +func (s *State) IsOnline() bool { + if s == nil { + return true + } + s.mu.Lock() + defer s.mu.Unlock() + return s.online +} + +// Changed returns a channel closed on the next availability transition, for +// callers that already own a select loop and cannot block in Wait. Re-read it +// after every fire: each transition installs a fresh channel. On a nil +// receiver — no State injected — it returns nil, which blocks forever in a +// select, so the caller simply never observes a transition. +func (s *State) Changed() <-chan struct{} { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.changed +} + +// Wait blocks while the network is offline. It reports whether it had to +// wait, so callers can reset their backoff after an outage. It returns early +// with the context error when ctx is done. On a nil receiver — no State +// injected — it returns immediately. +func (s *State) Wait(ctx context.Context) (bool, error) { + if s == nil { + return false, nil + } + waited := false + for { + s.mu.Lock() + if s.online { + s.mu.Unlock() + return waited, nil + } + ch := s.changed + s.mu.Unlock() + + if !waited { + waited = true + log.Debugf("network is offline, pausing connection attempts") + } + + select { + case <-ctx.Done(): + return waited, ctx.Err() + case <-ch: + } + } +} diff --git a/client/netstate/netstate_test.go b/client/netstate/netstate_test.go new file mode 100644 index 000000000..ea7015761 --- /dev/null +++ b/client/netstate/netstate_test.go @@ -0,0 +1,170 @@ +package netstate + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStateIsOnline(t *testing.T) { + assert.True(t, New().IsOnline(), "a fresh State should start online") +} + +func TestSetTogglesOnlineState(t *testing.T) { + s := New() + + s.Set(false) + assert.False(t, s.IsOnline(), "state should be offline after Set(false)") + + s.Set(true) + assert.True(t, s.IsOnline(), "state should be online after Set(true)") +} + +func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + waited, err := s.Wait(ctx) + require.NoError(t, err) + assert.False(t, waited, "Wait should not block when the network is online") +} + +func TestWaitBlocksUntilOnline(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result := make(chan bool, 1) + go func() { + waited, err := s.Wait(ctx) + if err != nil { + result <- false + return + } + result <- waited + }() + + // Verify Wait is actually blocking while offline + select { + case <-result: + t.Fatal("Wait should block while the network is offline") + case <-time.After(100 * time.Millisecond): + } + + s.Set(true) + + select { + case waited := <-result: + assert.True(t, waited, "Wait should report that it had to wait for the network") + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after the network becomes available") + } +} + +func TestWaitReturnsOnContextCancel(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithCancel(context.Background()) + + result := make(chan error, 1) + go func() { + _, err := s.Wait(ctx) + result <- err + }() + + cancel() + + select { + case err := <-result: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after context cancellation") + } +} + +func TestWaitWakesAllWaiters(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const waiters = 10 + var wg sync.WaitGroup + results := make(chan bool, waiters) + for i := 0; i < waiters; i++ { + wg.Add(1) + go func() { + defer wg.Done() + waited, err := s.Wait(ctx) + if err != nil { + results <- false + return + } + results <- waited + }() + } + + time.Sleep(100 * time.Millisecond) + s.Set(true) + wg.Wait() + + close(results) + count := 0 + for waited := range results { + assert.True(t, waited, "every waiter should report that it waited") + count++ + } + assert.Equal(t, waiters, count, "all waiters should have returned") +} + +func TestNilStateReadsAreNoops(t *testing.T) { + var s *State + + assert.True(t, s.IsOnline(), "nil State should report online") + + waited, err := s.Wait(context.Background()) + require.NoError(t, err) + assert.False(t, waited, "nil State's Wait should not block") +} + +func TestConcurrentSetAndWait(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + s.Set(j%2 == 0) + s.IsOnline() + } + }() + } + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + if _, err := s.Wait(ctx); err != nil { + return + } + } + }() + } + + wg.Wait() +} diff --git a/client/netsweep/netsweep.go b/client/netsweep/netsweep.go new file mode 100644 index 000000000..46bc0a709 --- /dev/null +++ b/client/netsweep/netsweep.go @@ -0,0 +1,267 @@ +// Package netsweep cuts network-bound activity when the OS switches networks: +// a sweep closes the registered connections and aborts the in-flight dials, so +// their owners redial immediately instead of waiting for the old sockets to +// time out. +// +// A nil *Sweeper disables everything: all methods are nil-safe no-ops. +package netsweep + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netstate" +) + +// DefaultSweepDelay absorbs network flapping while the OS settles on a +// default network before the stale registrations are cut. +const DefaultSweepDelay = 500 * time.Millisecond + +const recentMarkWindow = 3 * time.Second + +// Config customizes a Sweeper. The zero value applies the defaults. +type Config struct { + // SweepDelay overrides DefaultSweepDelay when positive. + SweepDelay time.Duration +} + +// ErrSwept reports that a dial finished after a network change swept its +// registration. The connection is already closed; the caller must treat it +// as a failed dial and redial on the new network. +var ErrSwept = errors.New("netsweep: connection swept by network change") + +// sweepID identifies one registration in a sweeper. Connections and dials +// draw from the same counter, so an id is unique across both registries. +type sweepID uint64 + +type connEntry struct { + conn net.Conn + gen uint64 +} + +// Dial tracks one dial from start to connection registration. It hands the +// dialed connection to the sweeper atomically, so a sweep can never fall +// between the dial finishing and the connection being registered. +type Dial struct { + sweeper *Sweeper + ctx context.Context + cancel context.CancelFunc + id sweepID + done bool // set by a sweep, WrapConn or Release; guarded by sweeper.mu + gen uint64 +} + +// Ctx returns the dial's context. A sweep cancels it, so a dial started on the +// old network aborts instead of waiting out its handshake timeout. +func (d *Dial) Ctx() context.Context { + return d.ctx +} + +// Release ends the dial's registration and cancels its context. It is +// idempotent and safe after WrapConn, so callers can defer it. +func (d *Dial) Release() { + s := d.sweeper + if s == nil { + return + } + + s.mu.Lock() + d.done = true + delete(s.dials, d.id) + s.mu.Unlock() + + d.cancel() +} + +// sweptConn deregisters itself from the sweeper when closed. +type sweptConn struct { + net.Conn + sweeper *Sweeper + id sweepID +} + +func (c *sweptConn) Close() error { + c.sweeper.deregister(c.id) + return c.Conn.Close() +} + +// Sweeper registers live connections and in-flight dials so the +// network-change sweep can cut everything registered before the change. +type Sweeper struct { + mu sync.Mutex + conns map[sweepID]connEntry + dials map[sweepID]*Dial + nextID sweepID + gen uint64 + timer *time.Timer + sweepDelay time.Duration + lastMark time.Time +} + +// New creates an empty sweeper with the default configuration. +func New() *Sweeper { + return NewWithConfig(Config{}) +} + +// NewWithConfig creates an empty sweeper customized by cfg. +func NewWithConfig(cfg Config) *Sweeper { + delay := cfg.SweepDelay + if delay <= 0 { + delay = DefaultSweepDelay + } + return &Sweeper{ + conns: make(map[sweepID]connEntry), + dials: make(map[sweepID]*Dial), + sweepDelay: delay, + } +} + +// StartDial registers an in-flight dial. Dial with Ctx, hand the result to +// WrapConn, and Release the dial when the attempt is over, typically deferred. +func (s *Sweeper) StartDial(ctx context.Context) *Dial { + if s == nil { + return &Dial{ctx: ctx} + } + + ctx, cancel := context.WithCancel(ctx) + d := &Dial{sweeper: s, ctx: ctx, cancel: cancel} + + s.mu.Lock() + d.id = s.nextID + s.nextID++ + d.gen = s.gen + s.dials[d.id] = d + s.mu.Unlock() + + return d +} + +// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial, +// the connection belongs to the old network: it is closed and ErrSwept is +// returned. Otherwise conn is registered against the next sweep and returned +// wrapped, deregistering itself on Close. Call it once, before Release. +func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) { + s := d.sweeper + if s == nil { + return conn, nil + } + + s.mu.Lock() + if d.done { + s.mu.Unlock() + if err := conn.Close(); err != nil { + log.Debugf("swept dial close error: %v", err) + } + return nil, ErrSwept + } + d.done = true + delete(s.dials, d.id) + id := s.nextID + s.nextID++ + // The conn inherits the dial's generation: the socket was bound to the + // network that was default when the dial started, not when it finished. + s.conns[id] = connEntry{conn: conn, gen: d.gen} + s.mu.Unlock() + + return &sweptConn{Conn: conn, sweeper: s, id: id}, nil +} + +// MarkNetworkChange records that the OS switched networks: everything +// registered so far becomes stale, and a sweep is (re)scheduled after the +// configured delay to cut whatever is still stale by then. Owners that +// redialed in the meantime hold fresh-generation registrations and survive, +// so no cancellation is needed around the sweep. +func (s *Sweeper) MarkNetworkChange() { + if s == nil { + return + } + + s.mu.Lock() + s.gen++ + cutoff := s.gen + s.lastMark = time.Now() + if s.timer != nil { + s.timer.Stop() + } + s.timer = time.AfterFunc(s.sweepDelay, func() { + n := s.sweep(cutoff) + log.Infof("network change sweep: closed %d stale connections", n) + }) + s.mu.Unlock() +} + +// QuickRetryBackoff wraps bo so that after each Reset the first retry comes +// quickly when the disconnect followed a recent network change and the +// network is online. Any other failure keeps bo's spread, so the clients of +// a restarted server still scatter their reconnects. A nil sweeper returns +// bo unchanged. +func (s *Sweeper) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff, netState *netstate.State) backoff.BackOff { + if s == nil { + return bo + } + return backoff.WithContext(newQuickRetryBackoff(bo, s, netState), ctx) +} + +func (s *Sweeper) markedRecently() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return !s.lastMark.IsZero() && time.Since(s.lastMark) < recentMarkWindow +} + +// sweep closes the registered connections and aborts the in-flight dials +// older than cutoff, and returns how many connections it closed. A dial +// whose connection was not yet handed to WrapConn is marked, so the late +// WrapConn closes it instead of registering it. +func (s *Sweeper) sweep(cutoff uint64) int { + if s == nil { + return 0 + } + + s.mu.Lock() + var conns []net.Conn + for id, e := range s.conns { + if e.gen < cutoff { + delete(s.conns, id) + conns = append(conns, e.conn) + } + } + var dials []*Dial + for id, d := range s.dials { + if d.gen < cutoff { + d.done = true + delete(s.dials, id) + dials = append(dials, d) + } + } + s.mu.Unlock() + + if len(dials) > 0 { + log.Debugf("aborting %d in-flight dials", len(dials)) + for _, d := range dials { + d.cancel() + } + } + + for _, conn := range conns { + log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr()) + if err := conn.Close(); err != nil { + log.Debugf("swept connection close error: %v", err) + } + } + return len(conns) +} + +func (s *Sweeper) deregister(id sweepID) { + s.mu.Lock() + delete(s.conns, id) + s.mu.Unlock() +} diff --git a/client/netsweep/netsweep_test.go b/client/netsweep/netsweep_test.go new file mode 100644 index 000000000..88d660c2d --- /dev/null +++ b/client/netsweep/netsweep_test.go @@ -0,0 +1,241 @@ +package netsweep + +import ( + "context" + "math" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSweepClosesRegisteredConns(t *testing.T) { + sweeper := New() + + c1 := wrap(t, sweeper, connPair(t)) + c2 := wrap(t, sweeper, connPair(t)) + + assert.Equal(t, 2, sweeper.sweepAll(), "both live connections should be closed") + + // The wrappers must report closed now. + buf := make([]byte, 1) + _, err := c1.Read(buf) + assert.Error(t, err, "first connection should be unusable after the sweep") + _, err = c2.Read(buf) + assert.Error(t, err, "second connection should be unusable after the sweep") + + assert.Equal(t, 0, sweeper.sweepAll(), "second sweep should find nothing") +} + +func TestCloseDeregisters(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + + assert.Equal(t, 0, sweeper.sweepAll(), "closed connection must leave the registry") +} + +func TestCloseIsIdempotent(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic") +} + +func TestSweepOnlyAffectsOlderConns(t *testing.T) { + sweeper := New() + + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll()) + + // A connection dialed after the sweep must survive until the next one. + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll(), "post-sweep connection belongs to the next sweep") +} + +func TestSweepAbortsInFlightDials(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + sweeper.sweepAll() + + assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context") +} + +func TestReleasedDialIsNotAborted(t *testing.T) { + sweeper := New() + + // Simulate a dial that finished before the sweep. + released := sweeper.StartDial(context.Background()) + released.Release() + + // A dial still in flight during the sweep. + pending := sweeper.StartDial(context.Background()) + defer pending.Release() + + sweeper.sweepAll() + assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted") +} + +func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + // The dial succeeds on the old network, then the sweep lands before the + // connection is handed over. + conn := connPair(t) + assert.Equal(t, 0, sweeper.sweepAll(), "the connection is not registered yet") + + wrapped, err := dial.WrapConn(conn) + require.ErrorIs(t, err, ErrSwept) + require.Nil(t, wrapped) + + buf := make([]byte, 1) + _, err = conn.Read(buf) + assert.Error(t, err, "the old-network connection must be closed, not leaked") + + assert.Equal(t, 0, sweeper.sweepAll(), "nothing may leak into the next sweep") +} + +func TestMarkNetworkChangeSparesFreshConns(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + _ = stale.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err := stale.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "stale connection must be closed by the delayed sweep") + + assert.Equal(t, 1, sweeper.sweepAll(), "the fresh connection must survive the stale sweep") +} + +func TestMarkNetworkChangeAbortsStaleDials(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := sweeper.StartDial(context.Background()) + defer stale.Release() + sweeper.MarkNetworkChange() + fresh := sweeper.StartDial(context.Background()) + defer fresh.Release() + + assert.Eventually(t, func() bool { + return stale.Ctx().Err() != nil + }, time.Second, 5*time.Millisecond, "stale dial must be aborted by the delayed sweep") + assert.NoError(t, fresh.Ctx().Err(), "post-mark dial must not be aborted") +} + +func TestConnInheritsDialGeneration(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + // The dial starts before the network change but completes after it: the + // socket is bound to the old network, so the sweep must still cut it. + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + sweeper.MarkNetworkChange() + + wrapped, err := dial.WrapConn(connPair(t)) + require.NoError(t, err) + + _ = wrapped.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err = wrapped.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "old-generation connection must be swept") +} + +func TestRepeatedMarksCoalesce(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + first := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + second := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + buf := make([]byte, 1) + for _, conn := range []net.Conn{first, second} { + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + _, err := conn.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "every pre-mark connection must be swept by the rescheduled sweep") + } + assert.Equal(t, 1, sweeper.sweepAll(), "only the newest-generation connection may remain") +} + +func TestNilSweeperIsNoop(t *testing.T) { + var sweeper *Sweeper + + conn := connPair(t) + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged") + assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context") + assert.Equal(t, 0, sweeper.sweepAll(), "nil sweeper closes nothing") +} + +// wrap registers conn with the sweeper through a completed dial. +func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn { + t.Helper() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + return wrapped +} + +// connPair dials a loopback TCP connection and keeps the accepted peer open +// until the test ends: a peer that closed early would make the connection +// unreadable on its own, so a read error after the sweep would prove nothing. +func connPair(t *testing.T) net.Conn { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Logf("listener close error: %v", err) + } + }) + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := l.Accept() + if err != nil { + close(accepted) + return + } + accepted <- conn + }() + + conn, err := net.Dial("tcp", l.Addr().String()) + require.NoError(t, err) + + peer, ok := <-accepted + require.True(t, ok, "listener must accept the dialed connection") + t.Cleanup(func() { + if err := peer.Close(); err != nil { + t.Logf("peer close error: %v", err) + } + }) + + return conn +} + +// sweepAll cuts every registration regardless of generation. +func (s *Sweeper) sweepAll() int { + return s.sweep(math.MaxUint64) +} diff --git a/client/netsweep/quick_retry.go b/client/netsweep/quick_retry.go new file mode 100644 index 000000000..524a5c50c --- /dev/null +++ b/client/netsweep/quick_retry.go @@ -0,0 +1,39 @@ +package netsweep + +import ( + "time" + + "github.com/cenkalti/backoff/v4" + + "github.com/netbirdio/netbird/client/netstate" +) + +const quickRetryDelay = 200 * time.Millisecond + +type quickRetryBackoff struct { + backoff.BackOff + sweeper *Sweeper + netState *netstate.State + used bool +} + +func newQuickRetryBackoff(bo backoff.BackOff, sweeper *Sweeper, netState *netstate.State) *quickRetryBackoff { + return &quickRetryBackoff{ + BackOff: bo, + sweeper: sweeper, + netState: netState, + } +} + +func (b *quickRetryBackoff) NextBackOff() time.Duration { + if !b.used && b.sweeper.markedRecently() && b.netState.IsOnline() { + b.used = true + return quickRetryDelay + } + return b.BackOff.NextBackOff() +} + +func (b *quickRetryBackoff) Reset() { + b.used = false + b.BackOff.Reset() +} diff --git a/client/netsweep/quick_retry_test.go b/client/netsweep/quick_retry_test.go new file mode 100644 index 000000000..5505862c5 --- /dev/null +++ b/client/netsweep/quick_retry_test.go @@ -0,0 +1,58 @@ +package netsweep + +import ( + "context" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" +) + +func TestQuickRetryAfterRecentMark(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "first retry after a mark must be quick") + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "second retry must fall back to the wrapped backoff") + + bo.Reset() + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "reset must re-arm the quick retry") +} + +func TestQuickRetryWithoutMarkKeepsSpread(t *testing.T) { + sweeper := New() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "without a mark the wrapped backoff decides") + + sweeper.mu.Lock() + sweeper.lastMark = time.Now().Add(-recentMarkWindow) + sweeper.mu.Unlock() + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "a stale mark must not trigger the quick retry") +} + +func TestQuickRetryNilSweeperPassthrough(t *testing.T) { + var sweeper *Sweeper + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, backoff.BackOff(slow), bo, "nil sweeper must return the backoff unchanged") +} + +func TestQuickRetryHonorsContext(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + bo := sweeper.QuickRetryBackoff(ctx, backoff.NewConstantBackOff(time.Millisecond), nil) + + assert.Equal(t, backoff.Stop, bo.NextBackOff(), "cancelled context must stop the retry loop") +} diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 81f25900a..cd250b5f7 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -21,6 +21,8 @@ import ( "google.golang.org/grpc/connectivity" nbgrpc "github.com/netbirdio/netbird/client/grpc" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/shared/management/domain" @@ -62,6 +64,13 @@ type GrpcClient struct { connStateCallbackLock sync.RWMutex serverURL string + // netState gates the stream retry loop on OS-reported network + // availability; nil (the default) disables gating. + netState *netstate.State + + // sweeper cuts the transport connections on network change; nil disables it. + sweeper *netsweep.Sweeper + // syncStreamErr holds the last Sync stream error, or nil while the stream // is established and healthy. GetServerKey succeeds even when the peer // cannot sync (e.g. the server returns "settings not found"), so the @@ -111,16 +120,43 @@ func MaxRecvMsgSize() int { return size } +// Option configures optional GrpcClient behavior. +type Option func(*GrpcClient) + +// WithNetworkState injects the OS network availability state that gates the +// stream retry loop; without it gating is disabled. +func WithNetworkState(netState *netstate.State) Option { + return func(c *GrpcClient) { c.netState = netState } +} + +// WithSweeper injects the network change sweeper. +func WithSweeper(sweeper *netsweep.Sweeper) Option { + return func(c *GrpcClient) { c.sweeper = sweeper } +} + // NewClient creates a new client to Management service -func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) { - var conn *grpc.ClientConn +func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) { + // Options apply before dialing: the sweeper must wrap the first connection too. + c := &GrpcClient{ + key: ourPrivateKey, + ctx: ctx, + connStateCallbackLock: sync.RWMutex{}, + serverURL: addr, + } + for _, opt := range opts { + opt(c) + } var extraOpts []grpc.DialOption if maxSize := MaxRecvMsgSize(); maxSize > 0 { extraOpts = append(extraOpts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxSize))) log.Infof("management gRPC max receive message size set to %d bytes", maxSize) } + if c.sweeper != nil { + extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper)) + } + var conn *grpc.ClientConn operation := func() error { var err error conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.ManagementComponent, extraOpts...) @@ -136,16 +172,9 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE return nil, err } - realClient := proto.NewManagementServiceClient(conn) - - return &GrpcClient{ - key: ourPrivateKey, - realClient: realClient, - ctx: ctx, - conn: conn, - connStateCallbackLock: sync.RWMutex{}, - serverURL: addr, - }, nil + c.conn = conn + c.realClient = proto.NewManagementServiceClient(conn) + return c, nil } // GetServerURL returns the management server URL @@ -206,16 +235,33 @@ func (c *GrpcClient) withMgmtStream( ctx context.Context, handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error, ) error { - backOff := defaultBackoff(ctx) + backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState) operation := func() error { - log.Debugf("management connection state %v", c.conn.GetState()) - connState := c.conn.GetState() + // suspend reconnect attempts while the OS reports no usable network. + // Wait only errors on a cancelled context, which means shutdown, so + // stop the loop without reporting a failure. + if waited, err := c.netState.Wait(ctx); err != nil { + log.Debugf("management connection context has been canceled while offline, this usually indicates shutdown") + return nil //nolint:nilerr // a cancelled context means shutdown, not a retryable failure + } else if waited { + backOff.Reset() + } + connState := c.conn.GetState() + log.Debugf("management connection state %v", connState) if connState == connectivity.Shutdown { return backoff.Permanent(fmt.Errorf("connection to management has been shut down")) - } else if !(connState == connectivity.Ready || connState == connectivity.Idle) { + } + if !(connState == connectivity.Ready || connState == connectivity.Idle) { + // A dial may already be in flight (e.g. the other stream triggered + // it after a network change); wait for it to settle and proceed if + // the channel became usable, instead of burning a backoff round on + // a successful dial. A failed dial errors out as before. c.conn.WaitForStateChange(ctx, connState) - return fmt.Errorf("connection to management is not ready and in %s state", connState) + connState = c.conn.GetState() + if !(connState == connectivity.Ready || connState == connectivity.Idle) { + return fmt.Errorf("connection to management is not ready and in %s state", connState) + } } serverPubKey, err := c.getServerPublicKey() @@ -227,7 +273,7 @@ func (c *GrpcClient) withMgmtStream( return handler(ctx, *serverPubKey, backOff) } - err := backoff.Retry(operation, backOff) + err := nbgrpc.Retry(ctx, operation, backOff, c.netState) if err != nil { log.Warnf("exiting the Management service connection retry loop due to the unrecoverable error: %s", err) } diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index 8d4aa6020..4fb30b8d9 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -14,6 +14,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/netsweep" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" "github.com/netbirdio/netbird/shared/relay/client/dialer" netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net" @@ -184,6 +185,10 @@ type Client struct { // datagram-sized transport is avoided on subsequent connects. Shared via // the manager. transportFallback *transportFallback + + // sweeper cuts the relay connection on network change; the read loop + // reports the disconnect and the guard reconnects. Shared via the manager. + sweeper *netsweep.Sweeper // datagramFallbackTriggered guards a single fallback per connection so a // burst of oversized datagrams triggers one reconnect, not many. datagramFallbackTriggered atomic.Bool @@ -393,6 +398,12 @@ func (c *Client) Close() error { } func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { + // A sweep cancels this context, so a dial started on the old network + // aborts instead of waiting out its handshake timeout. + dial := c.sweeper.StartDial(ctx) + defer dial.Release() + ctx = dial.Ctx() + mode := transportModeFromEnv() dialers := c.getDialers(mode) @@ -417,12 +428,19 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { return nil, fmt.Errorf("dial via FQDN: %w", err) } } - c.relayConn = conn - c.datagramFallbackTriggered.Store(false) + // Read the transport off the concrete connection: the sweeper's wrapper + // embeds net.Conn only, so it does not promote Protocol(). if tc, ok := conn.(transportConn); ok { c.transport = tc.Protocol() } + conn, err := dial.WrapConn(conn) + if err != nil { + return nil, fmt.Errorf("register connection: %w", err) + } + c.relayConn = conn + c.datagramFallbackTriggered.Store(false) + instanceURL, err := c.handShake(ctx) if err != nil { cErr := conn.Close() diff --git a/shared/relay/client/guard.go b/shared/relay/client/guard.go index d18534d9d..a62f8772d 100644 --- a/shared/relay/client/guard.go +++ b/shared/relay/client/guard.go @@ -7,9 +7,22 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netstate" ) -const defaultMaxBackoffInterval = 60 * time.Second +const ( + defaultMaxBackoffInterval = 60 * time.Second + + // quickReconnectBudget bounds how long a quick reconnect waits for the + // network before handing the retry over to the ticker. + quickReconnectBudget = 1500 * time.Millisecond + + // verdictSettleWindow is how long an online verdict must hold before it + // is trusted: the disconnect often precedes the OS offline flag by a few + // milliseconds. + verdictSettleWindow = 200 * time.Millisecond +) // Guard manage the reconnection tries to the Relay server in case of disconnection event. type Guard struct { @@ -22,14 +35,19 @@ type Guard struct { // attempts. maxBackoffInterval time.Duration + // netState gates reconnect attempts on OS-reported network availability; + // nil disables gating. + netState *netstate.State + // lastErr is the error from the most recent failed reconnect attempt, // surfaced as the home relay status while disconnected. lastErr atomic.Pointer[error] } // NewGuard creates a new guard for the relay client. A non-positive -// maxBackoffInterval falls back to defaultMaxBackoffInterval. -func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard { +// maxBackoffInterval falls back to defaultMaxBackoffInterval. A nil netState +// disables network availability gating. +func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netState *netstate.State) *Guard { if maxBackoffInterval <= 0 { maxBackoffInterval = defaultMaxBackoffInterval } @@ -38,6 +56,7 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard { OnReconnected: make(chan struct{}, 1), serverPicker: sp, maxBackoffInterval: maxBackoffInterval, + netState: netState, } return g } @@ -70,11 +89,21 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) { // start a ticker to pick a new server ticker := g.exponentTicker(ctx) - defer ticker.Stop() + defer func() { + ticker.Stop() + }() for { select { case <-ticker.C: + // suspend reconnect attempts while the OS reports no usable network + if waited, err := g.netState.Wait(ctx); err != nil { + return + } else if waited { + ticker.Stop() + ticker = g.exponentTicker(ctx) + continue + } if err := g.retry(ctx); err != nil { log.Errorf("failed to pick new Relay server: %s", err) g.setLastError(err) @@ -100,7 +129,12 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool return false } - if cancelled := waiteBeforeRetry(parentCtx); !cancelled { + if ok := g.waitForNetwork(parentCtx); !ok { + return false + } + + // Still offline after the budget: leave the retry to the ticker. + if !g.netState.IsOnline() { return false } @@ -166,14 +200,47 @@ func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker { return backoff.NewTicker(bo) } -func waiteBeforeRetry(ctx context.Context) bool { - timer := time.NewTimer(1500 * time.Millisecond) - defer timer.Stop() +// waitForNetwork waits out the settle window while online, or waits for the +// network to return while offline, within the budget. Returns false when ctx +// is cancelled. Without an injected netState it degrades to a fixed +// budget-long sleep, the pre-netstate behavior. +func (g *Guard) waitForNetwork(ctx context.Context) bool { + budget := time.NewTimer(quickReconnectBudget) + defer budget.Stop() - select { - case <-timer.C: - return true - case <-ctx.Done(): - return false + settleWindow := verdictSettleWindow + if g.netState == nil { + settleWindow = quickReconnectBudget + } + settle := time.NewTimer(settleWindow) + defer settle.Stop() + + for { + // Channel first, flag second: a flip in between still fires the channel. + changedCh := g.netState.Changed() + if g.netState.IsOnline() { + select { + case <-settle.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } else { + select { + case <-budget.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } + if !settle.Stop() { + select { + case <-settle.C: + default: + } + } + settle.Reset(settleWindow) } } diff --git a/shared/relay/client/guard_test.go b/shared/relay/client/guard_test.go new file mode 100644 index 000000000..0e05783e0 --- /dev/null +++ b/shared/relay/client/guard_test.go @@ -0,0 +1,30 @@ +package client + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/netstate" +) + +func TestWaitForNetworkSettlesAfterOutage(t *testing.T) { + ns := netstate.New() + ns.Set(false) + g := NewGuard(nil, 0, ns) + + const outage = 2 * verdictSettleWindow + start := time.Now() + go func() { + time.Sleep(outage) + ns.Set(true) + }() + + ok := g.waitForNetwork(context.Background()) + elapsed := time.Since(start) + + assert.True(t, ok, "recovered network must let the quick reconnect proceed") + assert.GreaterOrEqual(t, elapsed, outage+verdictSettleWindow, "reconnect must wait a full settle window after the network returns") +} diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index 2f2839d94..80e38ae2d 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -12,6 +12,8 @@ import ( log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" relayAuth "github.com/netbirdio/netbird/shared/relay/auth/hmac" ) @@ -65,6 +67,17 @@ func WithMaxBackoffInterval(d time.Duration) ManagerOption { return func(m *Manager) { m.maxBackoffInterval = d } } +// WithNetworkState injects the OS network availability state that gates the +// reconnect guard; without it reconnect attempts are not gated. +func WithNetworkState(netState *netstate.State) ManagerOption { + return func(m *Manager) { m.netState = netState } +} + +// WithSweeper injects the network change sweeper. +func WithSweeper(sweeper *netsweep.Sweeper) ManagerOption { + return func(m *Manager) { m.sweeper = sweeper } +} + // Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL // and automatically reconnect to them in case disconnection. // The manager also manage temporary relay connection. If a client wants to communicate with a client on a @@ -92,6 +105,8 @@ type Manager struct { mtu uint16 maxBackoffInterval time.Duration + netState *netstate.State + sweeper *netsweep.Sweeper cleanupInterval time.Duration keepUnusedServerTime time.Duration @@ -128,8 +143,9 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin for _, opt := range opts { opt(m) } + m.serverPicker.Sweeper = m.sweeper m.serverPicker.ServerURLs.Store(serverURLs) - m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval) + m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netState) return m } @@ -354,6 +370,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu) relayClient.SetTransportFallback(m.transportFallback) + relayClient.sweeper = m.sweeper err := relayClient.Connect(m.ctx) if err != nil { rt.Lock() diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go index bb721e4ad..72789fadc 100644 --- a/shared/relay/client/picker.go +++ b/shared/relay/client/picker.go @@ -9,6 +9,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/netsweep" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" ) @@ -30,6 +31,7 @@ type ServerPicker struct { MTU uint16 ConnectionTimeout time.Duration TransportFallback *transportFallback + Sweeper *netsweep.Sweeper } func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { @@ -73,6 +75,7 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con log.Infof("try to connecting to relay server: %s", url) relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU) relayClient.SetTransportFallback(sp.TransportFallback) + relayClient.sweeper = sp.Sweeper err := relayClient.Connect(ctx) resultChan <- connResult{ RelayClient: relayClient, diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index a07867263..73c482e8f 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -19,6 +19,8 @@ import ( "google.golang.org/grpc/status" nbgrpc "github.com/netbirdio/netbird/client/grpc" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/signal/proto" @@ -65,6 +67,13 @@ type GrpcClient struct { connStateCallback ConnStateNotifier connStateCallbackLock sync.RWMutex + // netState gates the Receive retry loop on OS-reported network + // availability; nil (the default) disables gating. + netState *netstate.State + + // sweeper cuts the transport connections on network change; nil disables it. + sweeper *netsweep.Sweeper + onReconnectedListenerFn func() decryptionWorker *Worker @@ -88,13 +97,43 @@ type GrpcClient struct { watchdogWg sync.WaitGroup } -// NewClient creates a new Signal client -func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) { - var conn *grpc.ClientConn +// Option configures optional GrpcClient behavior. +type Option func(*GrpcClient) +// WithNetworkState injects the OS network availability state that gates the +// Receive retry loop; without it gating is disabled. +func WithNetworkState(netState *netstate.State) Option { + return func(c *GrpcClient) { c.netState = netState } +} + +// WithSweeper injects the network change sweeper. +func WithSweeper(sweeper *netsweep.Sweeper) Option { + return func(c *GrpcClient) { c.sweeper = sweeper } +} + +// NewClient creates a new Signal client +func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) { + // Options apply before dialing: the sweeper must wrap the first connection too. + c := &GrpcClient{ + ctx: ctx, + key: key, + mux: sync.Mutex{}, + status: StreamDisconnected, + connStateCallbackLock: sync.RWMutex{}, + } + for _, opt := range opts { + opt(c) + } + + var extraOpts []grpc.DialOption + if c.sweeper != nil { + extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper)) + } + + var conn *grpc.ClientConn operation := func() error { var err error - conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent) + conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent, extraOpts...) if err != nil { return fmt.Errorf("create connection: %w", err) } @@ -109,15 +148,9 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo log.Debugf("connected to Signal Service: %v", conn.Target()) - return &GrpcClient{ - realClient: proto.NewSignalExchangeClient(conn), - ctx: ctx, - signalConn: conn, - key: key, - mux: sync.Mutex{}, - status: StreamDisconnected, - connStateCallbackLock: sync.RWMutex{}, - }, nil + c.signalConn = conn + c.realClient = proto.NewSignalExchangeClient(conn) + return c, nil } func (c *GrpcClient) StreamConnected() bool { @@ -165,19 +198,36 @@ func defaultBackoff(ctx context.Context) backoff.BackOff { // The connection retry logic will try to reconnect for 30 min and if wasn't successful will propagate the error to the function caller. func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error { - var backOff = defaultBackoff(ctx) + backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState) operation := func() error { + // suspend reconnect attempts while the OS reports no usable network. + // Wait only errors on a cancelled context, which means shutdown, so + // stop the loop without reporting a failure. + if waited, err := c.netState.Wait(ctx); err != nil { + log.Debugf("signal connection context has been canceled while offline, this usually indicates shutdown") + return nil + } else if waited { + backOff.Reset() + } c.notifyStreamDisconnected() - log.Debugf("signal connection state %v", c.signalConn.GetState()) connState := c.signalConn.GetState() + log.Debugf("signal connection state %v", connState) if connState == connectivity.Shutdown { return backoff.Permanent(fmt.Errorf("connection to signal has been shut down")) - } else if !(connState == connectivity.Ready || connState == connectivity.Idle) { + } + if !(connState == connectivity.Ready || connState == connectivity.Idle) { + // A dial may already be in flight (e.g. triggered by another RPC + // after a network change); wait for it to settle and proceed if + // the channel became usable, instead of burning a backoff round on + // a successful dial. A failed dial errors out as before. c.signalConn.WaitForStateChange(ctx, connState) - return fmt.Errorf("connection to signal is not ready and in %s state", connState) + connState = c.signalConn.GetState() + if !(connState == connectivity.Ready || connState == connectivity.Idle) { + return fmt.Errorf("connection to signal is not ready and in %s state", connState) + } } // connect to Signal stream identifying ourselves with a public WireGuard key @@ -231,7 +281,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes return nil } - err := backoff.Retry(operation, backOff) + err := nbgrpc.Retry(ctx, operation, backOff, c.netState) if err != nil { log.Errorf("exiting the Signal service connection retry loop due to the unrecoverable error: %v", err) return err From 6d223042eb906bb462791522de340cfea6705700 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 19 Aug 2026 08:27:06 +0000 Subject: [PATCH 17/36] [client] Clear stale installer result before starting update (#7204) * [client] Clear stale installer result before starting update The installer result file could survive a previous update attempt (e.g. when the updater wrote it after the restarted daemon already ran its startup check). A new install attempt left the old file in place, so the GUI progress window's first GetInstallerResult poll read the outdated result: a stale success made the GUI quit mid-install, which cancelled the TriggerUpdate context and aborted the artifact verification; a stale error surfaced a bogus failure dialog for a succeeding update. Remove any leftover result file at the start of RunInstallation, before the download begins, so result watchers only see the current attempt's outcome. * [client] Align stale-result warning with log message style --- client/internal/updater/installer/installer_common.go | 3 +++ client/internal/updater/installer/result.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go index 8e44bee82..17566f7de 100644 --- a/client/internal/updater/installer/installer_common.go +++ b/client/internal/updater/installer/installer_common.go @@ -42,6 +42,9 @@ func NewWithDir(tempDir string) *Installer { // This will run by the original service process func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) { resultHandler := NewResultHandler(u.tempDir) + if err := resultHandler.ClearStaleResult(); err != nil { + log.Warnf("clear stale installer result: %v", err) + } defer func() { if err != nil { diff --git a/client/internal/updater/installer/result.go b/client/internal/updater/installer/result.go index 526c3eb53..55a0d8ac8 100644 --- a/client/internal/updater/installer/result.go +++ b/client/internal/updater/installer/result.go @@ -54,6 +54,12 @@ func (rh *ResultHandler) GetErrorResultReason() string { return "" } +// ClearStaleResult removes a result file left over from a previous installation +// attempt so result watchers cannot read an outdated outcome for the current attempt. +func (rh *ResultHandler) ClearStaleResult() error { + return rh.cleanup() +} + func (rh *ResultHandler) WriteSuccess() error { result := Result{ Success: true, From ad98b99fc5712ae90f6d3a0e3ac6d4406a5784d7 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 19 Aug 2026 09:41:24 +0000 Subject: [PATCH 18/36] [client] Stop the UI before a silent Windows update and suppress the installer reboot (#7209) Stop the UI before a silent Windows update and suppress the installer reboot On silent MSI updates msiexec could reboot the machine on its own. The running UI holds a lock on its own exe, and since msiexec runs as LocalSystem it cannot close the interactive user's UI via Restart Manager, so the MSI scheduled the file replacement for the next reboot and marked the install restart-required. Terminate netbird-ui.exe before launching the installer and wait until its image file is released; the existing deferred restart brings it back after the install on every exit path Run msiexec with /norestart REBOOT=ReallySuppress so it never reboots on its own Treat exit codes 3010/1641 as success with a warning instead of a failure --------- Co-authored-by: Viktor Liu --- client/internal/updater/installer/doc.go | 29 +-- .../installer/installer_run_windows.go | 170 ++++++++++++++++-- .../installer/installer_run_windows_test.go | 108 +++++++++++ 3 files changed, 286 insertions(+), 21 deletions(-) create mode 100644 client/internal/updater/installer/installer_run_windows_test.go diff --git a/client/internal/updater/installer/doc.go b/client/internal/updater/installer/doc.go index 0a60454bb..11b0512ac 100644 --- a/client/internal/updater/installer/doc.go +++ b/client/internal/updater/installer/doc.go @@ -37,23 +37,32 @@ // Updater Process (Setup): // // 1. Receives parameters from service via command-line arguments -// 2. Runs installer with appropriate silent/quiet flags: +// 2. Terminates the UI so the installer does not have to replace a locked image +// file, which would otherwise leave the install needing a reboot +// 3. Runs installer with appropriate silent/quiet flags: // - Windows EXE: installer.exe /S -// - Windows MSI: msiexec.exe /i installer.msi /quiet /qn /l*v msi.log +// - Windows MSI: msiexec.exe /i installer.msi /qn /norestart REBOOT=ReallySuppress /l*v msi.log // - macOS PKG: installer -pkg installer.pkg -target / // - macOS Homebrew: brew upgrade netbirdio/tap/netbird -// 3. Installer terminates daemon and UI processes -// 4. Installer replaces binaries with new version -// 5. Updater waits for installer to complete -// 6. Updater restarts daemon: +// 4. Installer terminates the daemon +// 5. Installer replaces binaries with new version +// 6. Updater waits for installer to complete. On Windows, MSI exit codes 3010 +// (ERROR_SUCCESS_REBOOT_REQUIRED) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED) +// are a pending-reboot outcome, not a failure: the install succeeded, but +// some files are only replaced on the next restart (the reboot itself is +// suppressed via /norestart and REBOOT=ReallySuppress), and the flow +// continues as on success +// 7. Updater restarts daemon: // - Windows: netbird.exe service start // - macOS/Linux: netbird service start -// 7. Updater restarts UI: -// - Windows: Launches netbird-ui.exe as active console user using CreateProcessAsUser +// 8. Updater restarts UI: +// - Windows: Launches netbird-ui.exe using CreateProcessAsUser in every +// session it was terminated in, falling back to the active console session // - macOS: Uses launchctl asuser to launch NetBird.app for console user // - Linux: Not implemented (UI typically auto-starts) -// 8. Updater writes result.json with success/error status -// 9. Updater process exits +// 9. Updater writes result.json with success/error status (a pending reboot is +// recorded as success) +// 10. Updater process exits // // # Result Communication // diff --git a/client/internal/updater/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go index 70c7e32cf..81da211b6 100644 --- a/client/internal/updater/installer/installer_run_windows.go +++ b/client/internal/updater/installer/installer_run_windows.go @@ -2,6 +2,7 @@ package installer import ( "context" + "errors" "fmt" "os" "os/exec" @@ -22,6 +23,12 @@ const ( msiLogFile = "msi.log" + // ERROR_SUCCESS_REBOOT_REQUIRED and ERROR_SUCCESS_REBOOT_INITIATED + msiRebootRequired = 3010 + msiRebootInitiated = 1641 + + processExitWait = 10 * time.Second + msiDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.msi" exeDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.exe" ) @@ -38,6 +45,8 @@ var ( func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) { resultHandler := NewResultHandler(u.tempDir) + var uiSessions []uint32 + // Always ensure daemon and UI are restarted after setup defer func() { log.Infof("starting daemon back") @@ -46,7 +55,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("starting UI back") - if err := u.startUIAsUser(daemonFolder); err != nil { + if err := u.startUI(daemonFolder, uiSessions); err != nil { log.Errorf("failed to start UI: %v", err) } @@ -75,6 +84,14 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string return } + // The UI holds an open handle on its own image. Left running, Restart Manager + // cannot shut it down (msiexec runs as LocalSystem here, the UI as the + // interactive user), so the MSI falls back to replacing the file on reboot and + // marks the install as restart-required. The deferred close-application action + // in the package runs too late to prevent that, it happens after + // InstallValidate has already registered the file as in use. + uiSessions = killUI() + var cmd *exec.Cmd switch installerType { case TypeExe: @@ -84,7 +101,9 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string installerDir := filepath.Dir(installerFile) logPath := filepath.Join(installerDir, msiLogFile) log.Infof("run msi installer: %s", installerFile) - cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/quiet", "/qn", "/l*v", logPath) + // REBOOT=ReallySuppress: a silent install has no way to ask, so without it + // msiexec reboots the machine on its own if it decides one is needed. + cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/qn", "/norestart", "REBOOT=ReallySuppress", "/l*v", logPath) } cmd.Dir = filepath.Dir(installerFile) @@ -95,9 +114,13 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("installer started with PID %d", cmd.Process.Pid) - if resultErr = cmd.Wait(); resultErr != nil { - log.Errorf("installer process finished with error: %v", resultErr) - return + if err := cmd.Wait(); err != nil { + if !isRebootPending(err) { + resultErr = err + log.Errorf("installer process finished with error: %v", err) + return + } + log.Warnf("installer completed but reported a pending reboot, some files will be replaced on the next restart") } return nil @@ -117,16 +140,142 @@ func (u *Installer) startDaemon(daemonFolder string) error { return nil } -func (u *Installer) startUIAsUser(daemonFolder string) error { +func (u *Installer) startUI(daemonFolder string, sessionIDs []uint32) error { uiPath := filepath.Join(daemonFolder, uiName) log.Infof("starting netbird-ui: %s", uiPath) - // Get the active console session ID - sessionID := windows.WTSGetActiveConsoleSessionId() - if sessionID == 0xFFFFFFFF { - return fmt.Errorf("no active user session found") + if len(sessionIDs) == 0 { + sessionID := windows.WTSGetActiveConsoleSessionId() + if sessionID == 0xFFFFFFFF { + return fmt.Errorf("no active user session found") + } + sessionIDs = []uint32{sessionID} } + var errs []error + for _, sessionID := range sessionIDs { + if err := startUIInSession(uiPath, sessionID); err != nil { + errs = append(errs, fmt.Errorf("session %d: %w", sessionID, err)) + continue + } + log.Infof("netbird-ui started successfully in session %d", sessionID) + } + return errors.Join(errs...) +} + +// isRebootPending reports whether the installer exit code means it succeeded but +// left work for the next restart. The reboot itself is suppressed, so this is not +// a failure. +func isRebootPending(err error) bool { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return false + } + + switch exitErr.ExitCode() { + case msiRebootRequired, msiRebootInitiated: + return true + default: + return false + } +} + +// killUI terminates any running netbird-ui process and returns the IDs of the +// interactive sessions the terminated processes belonged to. Setup starts the +// UI again in those sessions once the installer is done. +func killUI() []uint32 { + pids, err := processIDsByName(uiName) + if err != nil { + log.Warnf("failed to look up %s processes: %v", uiName, err) + return nil + } + + sessions := make(map[uint32]struct{}) + for _, pid := range pids { + var sessionID uint32 + if err := windows.ProcessIdToSessionId(pid, &sessionID); err != nil { + log.Warnf("failed to look up session of %s (PID %d): %v", uiName, pid, err) + } + + if err := terminateProcess(pid); err != nil { + log.Warnf("failed to terminate %s (PID %d): %v", uiName, pid, err) + continue + } + log.Infof("terminated %s (PID %d) in session %d", uiName, pid, sessionID) + + if sessionID != 0 { + sessions[sessionID] = struct{}{} + } + } + + sessionIDs := make([]uint32, 0, len(sessions)) + for sessionID := range sessions { + sessionIDs = append(sessionIDs, sessionID) + } + return sessionIDs +} + +func processIDsByName(name string) ([]uint32, error) { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("create process snapshot: %w", err) + } + defer func() { + if err := windows.CloseHandle(snapshot); err != nil { + log.Warnf("failed to close process snapshot: %v", err) + } + }() + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + var pids []uint32 + for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) { + if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) { + pids = append(pids, entry.ProcessID) + } + } + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil, fmt.Errorf("enumerate processes: %w", err) + } + + return pids, nil +} + +func terminateProcess(pid uint32) error { + handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid) + if err != nil { + // The process may have exited between enumeration and now. + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + return fmt.Errorf("open process: %w", err) + } + defer func() { + if err := windows.CloseHandle(handle); err != nil { + log.Warnf("failed to close process handle: %v", err) + } + }() + + if err := windows.TerminateProcess(handle, 0); err != nil { + return fmt.Errorf("terminate process: %w", err) + } + + // Wait for the handle to signal so the image file is released before the + // installer tries to overwrite it. A timeout is reported through the returned + // event, not through err, which stays nil unless the wait itself failed. + event, err := windows.WaitForSingleObject(handle, uint32(processExitWait.Milliseconds())) + if err != nil { + return fmt.Errorf("wait for process exit: %w", err) + } + if event != windows.WAIT_OBJECT_0 { + return fmt.Errorf("wait for process exit: unexpected wait result %#x", event) + } + + return nil +} + +func startUIInSession(uiPath string, sessionID uint32) error { // Get the user token for that session var userToken windows.Token err := windows.WTSQueryUserToken(sessionID, &userToken) @@ -197,7 +346,6 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { log.Warnf("failed to close thread handle: %v", err) } - log.Infof("netbird-ui started successfully in session %d", sessionID) return nil } diff --git a/client/internal/updater/installer/installer_run_windows_test.go b/client/internal/updater/installer/installer_run_windows_test.go new file mode 100644 index 000000000..6a4540610 --- /dev/null +++ b/client/internal/updater/installer/installer_run_windows_test.go @@ -0,0 +1,108 @@ +package installer + +import ( + "errors" + "os/exec" + "slices" + "strconv" + "testing" +) + +// exitErrorWithCode returns a real *exec.ExitError carrying the given exit code. +func exitErrorWithCode(t *testing.T, code int) error { + t.Helper() + + err := exec.Command("cmd.exe", "/c", "exit "+strconv.Itoa(code)).Run() + if err == nil { + t.Fatalf("expected a non-zero exit for code %d", code) + } + return err +} + +func TestIsRebootPending(t *testing.T) { + tests := []struct { + name string + code int + want bool + }{ + {name: "reboot required", code: msiRebootRequired, want: true}, + {name: "reboot initiated", code: msiRebootInitiated, want: true}, + {name: "generic failure", code: 1603, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRebootPending(exitErrorWithCode(t, tt.code)); got != tt.want { + t.Errorf("isRebootPending(exit %d) = %v, want %v", tt.code, got, tt.want) + } + }) + } +} + +// TestProcessIDsByNameAndTerminate spawns a long-running system process, finds it +// by name and terminates it, covering the path the updater uses to release the UI +// image file before the installer replaces it. +func TestProcessIDsByNameAndTerminate(t *testing.T) { + cmd := exec.Command("ping.exe", "-n", "60", "127.0.0.1") + if err := cmd.Start(); err != nil { + t.Fatalf("start ping: %v", err) + } + + pid := uint32(cmd.Process.Pid) + killed := false + t.Cleanup(func() { + if !killed { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + // Name matching must be case-insensitive: the snapshot reports PING.EXE. + pids, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + + if !slices.Contains(pids, pid) { + t.Fatalf("PID %d not among the ping.exe processes found: %v", pid, pids) + } + + if err := terminateProcess(pid); err != nil { + t.Fatalf("terminateProcess: %v", err) + } + killed = true + + // terminateProcess only returns once the handle has signalled, so the process + // is already gone and Wait must not block. It exits with the code passed to + // TerminateProcess, which is 0, so Wait reports no error. + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for terminated ping: %v", err) + } + if !cmd.ProcessState.Exited() { + t.Error("process did not exit after terminateProcess") + } + + remaining, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName after terminate: %v", err) + } + if slices.Contains(remaining, pid) { + t.Errorf("PID %d still listed after terminateProcess", pid) + } +} + +func TestProcessIDsByNameNoMatch(t *testing.T) { + pids, err := processIDsByName("netbird-nonexistent-process.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + if len(pids) != 0 { + t.Errorf("expected no matches, got %v", pids) + } +} + +func TestIsRebootPendingNonExitError(t *testing.T) { + if isRebootPending(errors.New("start installer: file not found")) { + t.Error("a non-exit error must not be treated as a pending reboot") + } +} From 77791b5858d1d9c9cfdb653fb710f0c6e0ddbb68 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 19 Aug 2026 09:47:57 +0000 Subject: [PATCH 19/36] [client] Report network addresses on Android for posture checks (#7235) Android never reported its local network interfaces, so PeerNetworkRange posture checks could not be evaluated: NetworkAddresses always arrived empty. net.Interfaces() is unusable on Android 11+ (SELinux blocks netlink), so the addresses are parsed from the interface description the host app already provides via stdnet.ExternalIFaceDiscover. The MAC filter is skipped, mirroring #5906 for iOS, since Android does not expose MACs either and nothing reads Mac server side. --- client/android/client.go | 1 + client/system/info_android.go | 6 ++ client/system/network_addr.go | 2 +- client/system/network_addr_android.go | 89 +++++++++++++++++++++++++++ client/system/network_addr_test.go | 2 +- 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 client/system/network_addr_android.go diff --git a/client/android/client.go b/client/android/client.go index 71bbe4380..7eea83dc0 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -152,6 +152,7 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd execWorkaround(androidSDKVersion) net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket) + system.SetIFaceDiscover(iFaceDiscover) return &Client{ deviceName: deviceName, uiVersion: uiVersion, diff --git a/client/system/info_android.go b/client/system/info_android.go index 3c71573bb..d4f479386 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -30,6 +30,11 @@ func GetInfo(ctx context.Context) *Info { kernelVersion = osInfo[2] } + addrs, err := networkAddresses() + if err != nil { + log.Warnf("discover network addresses: %s", err) + } + gio := &Info{ GoOS: runtime.GOOS, Kernel: kernel, @@ -41,6 +46,7 @@ func GetInfo(ctx context.Context) *Info { NetbirdVersion: version.NetbirdVersion(), UIVersion: extractUIVersion(ctx), KernelVersion: kernelVersion, + NetworkAddresses: addrs, SystemSerialNumber: serial(), SystemProductName: productModel(), SystemManufacturer: productManufacturer(), diff --git a/client/system/network_addr.go b/client/system/network_addr.go index 44260a938..505a6f0ea 100644 --- a/client/system/network_addr.go +++ b/client/system/network_addr.go @@ -1,4 +1,4 @@ -//go:build !ios +//go:build !ios && !android package system diff --git a/client/system/network_addr_android.go b/client/system/network_addr_android.go new file mode 100644 index 000000000..99a71e105 --- /dev/null +++ b/client/system/network_addr_android.go @@ -0,0 +1,89 @@ +package system + +import ( + "net/netip" + "strings" +) + +var iFaceDiscover IFaceDiscover + +type IFaceDiscover interface { + IFaces() (string, error) +} + +// SetIFaceDiscover configures the Android interface discovery provider. +func SetIFaceDiscover(discover IFaceDiscover) { + iFaceDiscover = discover +} + +func networkAddresses() ([]NetworkAddress, error) { + if iFaceDiscover == nil { + return nil, nil + } + ifaces, err := iFaceDiscover.IFaces() + if err != nil { + return nil, err + } + + var netAddresses []NetworkAddress + for _, line := range strings.Split(ifaces, "\n") { + addresses, ok := interfaceAddresses(line) + if !ok { + continue + } + for _, address := range addresses { + netAddr, ok := toNetworkAddress(address) + if !ok { + continue + } + if isDuplicated(netAddresses, netAddr) { + continue + } + netAddresses = append(netAddresses, netAddr) + } + } + return netAddresses, nil +} + +func interfaceAddresses(line string) ([]string, bool) { + parts := strings.Split(line, "|") + if len(parts) != 2 { + return nil, false + } + flags := strings.Fields(parts[0]) + if len(flags) != 8 { + return nil, false + } + up, loopback := flags[3], flags[5] + if up != "true" || loopback == "true" { + return nil, false + } + return strings.Fields(parts[1]), true +} + +func toNetworkAddress(address string) (NetworkAddress, bool) { + prefix, err := netip.ParsePrefix(address) + if err != nil { + return NetworkAddress{}, false + } + if prefix.Addr().Is4In6() { + if prefix.Bits() < 96 { + return NetworkAddress{}, false + } + prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96) + } + ip := prefix.Addr() + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() { + return NetworkAddress{}, false + } + return NetworkAddress{NetIP: prefix}, true +} + +func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { + for _, duplicated := range addresses { + if duplicated.NetIP == addr.NetIP { + return true + } + } + return false +} diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go index a5f9c4279..b0be40f0a 100644 --- a/client/system/network_addr_test.go +++ b/client/system/network_addr_test.go @@ -1,4 +1,4 @@ -//go:build !ios +//go:build !ios && !android package system From 9efa3c6579fe5b193fa72b8e717a0bfac014d0f0 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 19 Aug 2026 10:19:10 +0000 Subject: [PATCH 20/36] [client] Start the restarted UI with the user's environment block (#7245) The updater runs as LocalSystem and started netbird-ui via CreateProcessAsUser with a nil environment, so the UI inherited the SYSTEM environment (USERPROFILE, APPDATA pointing at systemprofile) while running under the user's token. The WebView2-based UI exits immediately in that state, so the UI never came back after an update. Build the environment from the user's token with CreateEnvironmentBlock and pass it to CreateProcessAsUser. --- .../updater/installer/installer_run_windows.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/internal/updater/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go index 81da211b6..b2ecf3299 100644 --- a/client/internal/updater/installer/installer_run_windows.go +++ b/client/internal/updater/installer/installer_run_windows.go @@ -307,6 +307,16 @@ func startUIInSession(uiPath string, sessionID uint32) error { } }() + var env *uint16 + if err := windows.CreateEnvironmentBlock(&env, primaryToken, false); err != nil { + return fmt.Errorf("create environment block: %w", err) + } + defer func() { + if err := windows.DestroyEnvironmentBlock(env); err != nil { + log.Warnf("failed to destroy environment block: %v", err) + } + }() + // Prepare startup info var si windows.StartupInfo si.Cb = uint32(unsafe.Sizeof(si)) @@ -329,7 +339,7 @@ func startUIInSession(uiPath string, sessionID uint32) error { nil, false, creationFlags, - nil, + env, nil, &si, &pi, From a144e8c14418f430e914250eed30786720d07fa3 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 20 Aug 2026 11:53:19 +0200 Subject: [PATCH 21/36] [client, management] switch to go.uber.org/mock (#7253) * switch to go.uber.org/mock/gomock Signed-off-by: Dmitri Dolguikh * updated go:generate commands + regenerated mocks Signed-off-by: Dmitri Dolguikh * update go:generate mockgen commands Signed-off-by: Dmitri Dolguikh * removed duplicate import Signed-off-by: Dmitri Dolguikh * fix go:generate Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- client/cmd/testutil_test.go | 2 +- client/embed/embed_test.go | 2 +- .../firewall/uspfilter/filter_filter_test.go | 2 +- .../uspfilter/filter_routeacl_test.go | 2 +- client/iface/device/device_filter_test.go | 2 +- client/iface/mocks/filter.go | 2 +- client/iface/mocks/tun.go | 2 +- client/internal/acl/manager_test.go | 2 +- client/internal/acl/mocks/iface_mapper.go | 2 +- client/internal/dns/response_writer_test.go | 2 +- client/internal/dns/server_privileged_test.go | 2 +- client/internal/engine_privileged_test.go | 2 +- client/server/server_privileged_test.go | 2 +- go.mod | 4 +- .../controllers/network_map/interface.go | 2 +- .../agentnetwork/handlers/handlers_test.go | 2 +- .../agentnetwork/policyselect_model_test.go | 2 +- .../modules/agentnetwork/policyselect_test.go | 2 +- .../modules/agentnetwork/reconcile_test.go | 2 +- .../agentnetwork/settings_bootstrap_test.go | 2 +- .../modules/agentnetwork/synthesizer_test.go | 2 +- .../modules/agentnetwork/wire_shape_test.go | 2 +- .../peers/ephemeral/manager/ephemeral_test.go | 2 +- management/internals/modules/peers/manager.go | 2 +- .../internals/modules/peers/manager_mock.go | 32 +- .../accesslogs/manager/manager_test.go | 2 +- .../modules/reverseproxy/proxy/manager.go | 2 +- .../reverseproxy/proxy/manager_mock.go | 155 ++--- .../reverseproxy/proxytoken/handler_test.go | 2 +- .../modules/reverseproxy/service/interface.go | 2 +- .../reverseproxy/service/interface_mock.go | 50 +- .../service/manager/l4_port_test.go | 2 +- .../service/manager/manager_test.go | 2 +- .../modules/zones/manager/manager_test.go | 2 +- .../zones/records/manager/manager_test.go | 2 +- .../server/server_resolve_domains_test.go | 2 +- .../grpc/proxy_connect_authorizer_test.go | 2 +- .../shared/grpc/proxy_snapshot_test.go | 2 +- .../shared/grpc/sync_mappings_test.go | 2 +- .../internals/shared/grpc/token_mgr_test.go | 2 +- management/server/account/manager.go | 2 +- management/server/account/manager_mock.go | 320 ++++----- management/server/account_test.go | 2 +- management/server/dns_test.go | 2 +- management/server/group_test.go | 2 +- .../accounts/accounts_handler_test.go | 2 +- .../instance/instance_handler_test.go | 2 +- .../http/handlers/peers/peers_handler_test.go | 5 +- .../policies/geolocation_handler_test.go | 2 +- management/server/identity_provider_test.go | 2 +- .../server/instance/setup_service_test.go | 2 +- management/server/management_proto_test.go | 2 +- management/server/management_test.go | 2 +- management/server/nameserver_test.go | 2 +- .../server/networks/resources/manager_test.go | 2 +- management/server/peer_test.go | 2 +- management/server/permissions/manager.go | 2 +- management/server/permissions/manager_mock.go | 18 +- management/server/route_test.go | 2 +- management/server/settings/manager.go | 2 +- management/server/settings/manager_mock.go | 46 +- management/server/store/store.go | 2 +- management/server/store/store_mock.go | 612 +++++++++--------- shared/management/client/client_test.go | 2 +- 64 files changed, 700 insertions(+), 652 deletions(-) diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index 205327ef5..f40056f83 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "google.golang.org/grpc" diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index a2f438975..27beb8934 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "google.golang.org/grpc" diff --git a/client/firewall/uspfilter/filter_filter_test.go b/client/firewall/uspfilter/filter_filter_test.go index a64c83138..5ca8538be 100644 --- a/client/firewall/uspfilter/filter_filter_test.go +++ b/client/firewall/uspfilter/filter_filter_test.go @@ -5,7 +5,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/stretchr/testify/require" diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go index 449554d8b..b6397d09b 100644 --- a/client/firewall/uspfilter/filter_routeacl_test.go +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -4,7 +4,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket/layers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go index 0d86c9323..a75ef90f9 100644 --- a/client/iface/device/device_filter_test.go +++ b/client/iface/device/device_filter_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" diff --git a/client/iface/mocks/filter.go b/client/iface/mocks/filter.go index 5ae98039c..ff3dd0c8a 100644 --- a/client/iface/mocks/filter.go +++ b/client/iface/mocks/filter.go @@ -8,7 +8,7 @@ import ( "net/netip" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" ) // MockPacketFilter is a mock of PacketFilter interface. diff --git a/client/iface/mocks/tun.go b/client/iface/mocks/tun.go index 677c82b0b..519ee6005 100644 --- a/client/iface/mocks/tun.go +++ b/client/iface/mocks/tun.go @@ -8,7 +8,7 @@ import ( os "os" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" tun "golang.zx2c4.com/wireguard/tun" ) diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 968654ae9..70ffefcce 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -5,7 +5,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/client/internal/acl/mocks/iface_mapper.go b/client/internal/acl/mocks/iface_mapper.go index 95d5a2c58..f8cca1c2d 100644 --- a/client/internal/acl/mocks/iface_mapper.go +++ b/client/internal/acl/mocks/iface_mapper.go @@ -7,7 +7,7 @@ package mocks import ( reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" wgdevice "golang.zx2c4.com/wireguard/device" "github.com/netbirdio/netbird/client/iface/device" diff --git a/client/internal/dns/response_writer_test.go b/client/internal/dns/response_writer_test.go index 857964406..bc8416029 100644 --- a/client/internal/dns/response_writer_test.go +++ b/client/internal/dns/response_writer_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/miekg/dns" diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go index a03aea169..a17044cf5 100644 --- a/client/internal/dns/server_privileged_test.go +++ b/client/internal/dns/server_privileged_test.go @@ -9,7 +9,7 @@ import ( "os" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index f787f741f..032992464 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index 8b6f78f04..0366ccb31 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" diff --git a/go.mod b/go.mod index beca63bfe..e8d65e568 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,6 @@ require ( github.com/goccy/go-yaml v1.18.0 github.com/godbus/dbus/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 github.com/google/gopacket v1.1.19 github.com/google/nftables v0.3.0 @@ -217,6 +216,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/mock v1.6.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect @@ -340,3 +340,5 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db + +tool go.uber.org/mock/mockgen diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index e6e464566..b535321d1 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -1,6 +1,6 @@ package network_map -//go:generate go run go.uber.org/mock/mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod +//go:generate go tool mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod import ( "context" diff --git a/management/internals/modules/agentnetwork/handlers/handlers_test.go b/management/internals/modules/agentnetwork/handlers/handlers_test.go index 9d855c05d..6d1be3562 100644 --- a/management/internals/modules/agentnetwork/handlers/handlers_test.go +++ b/management/internals/modules/agentnetwork/handlers/handlers_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/agentnetwork/policyselect_model_test.go b/management/internals/modules/agentnetwork/policyselect_model_test.go index c122cc36c..7ae13e4ef 100644 --- a/management/internals/modules/agentnetwork/policyselect_model_test.go +++ b/management/internals/modules/agentnetwork/policyselect_model_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/agentnetwork/policyselect_test.go b/management/internals/modules/agentnetwork/policyselect_test.go index dd7687fe1..9ca548344 100644 --- a/management/internals/modules/agentnetwork/policyselect_test.go +++ b/management/internals/modules/agentnetwork/policyselect_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/agentnetwork/reconcile_test.go b/management/internals/modules/agentnetwork/reconcile_test.go index cda3a9549..ab3b08481 100644 --- a/management/internals/modules/agentnetwork/reconcile_test.go +++ b/management/internals/modules/agentnetwork/reconcile_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/agentnetwork/settings_bootstrap_test.go b/management/internals/modules/agentnetwork/settings_bootstrap_test.go index 0389ed4f5..fc6fd8b82 100644 --- a/management/internals/modules/agentnetwork/settings_bootstrap_test.go +++ b/management/internals/modules/agentnetwork/settings_bootstrap_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 387f44b74..817129571 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/agentnetwork/wire_shape_test.go b/management/internals/modules/agentnetwork/wire_shape_test.go index 779dd77f9..c8877731e 100644 --- a/management/internals/modules/agentnetwork/wire_shape_test.go +++ b/management/internals/modules/agentnetwork/wire_shape_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go b/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go index 314e84501..1b64c447a 100644 --- a/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 6f292f6ed..3274ec524 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -1,6 +1,6 @@ package peers -//go:generate go run github.com/golang/mock/mockgen -package peers -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +//go:generate go tool mockgen -package peers -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod import ( "context" diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go index 3836ac909..8c26d43b1 100644 --- a/management/internals/modules/peers/manager_mock.go +++ b/management/internals/modules/peers/manager_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./manager.go +// +// Generated by this command: +// +// mockgen -package peers -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +// // Package peers is a generated GoMock package. package peers @@ -9,18 +14,19 @@ import ( net "net" reflect "reflect" - gomock "github.com/golang/mock/gomock" network_map "github.com/netbirdio/netbird/management/internals/controllers/network_map" account "github.com/netbirdio/netbird/management/server/account" integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" peer "github.com/netbirdio/netbird/management/server/peer" types "github.com/netbirdio/netbird/management/server/types" + gomock "go.uber.org/mock/gomock" ) // MockManager is a mock of Manager interface. type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. @@ -49,7 +55,7 @@ func (m *MockManager) CreateProxyPeer(ctx context.Context, accountID, peerKey, c } // CreateProxyPeer indicates an expected call of CreateProxyPeer. -func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProxyPeer", reflect.TypeOf((*MockManager)(nil).CreateProxyPeer), ctx, accountID, peerKey, cluster) } @@ -63,7 +69,7 @@ func (m *MockManager) DeletePeers(ctx context.Context, accountID string, peerIDs } // DeletePeers indicates an expected call of DeletePeers. -func (mr *MockManagerMockRecorder) DeletePeers(ctx, accountID, peerIDs, userID, checkConnected interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeletePeers(ctx, accountID, peerIDs, userID, checkConnected any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected) } @@ -78,7 +84,7 @@ func (m *MockManager) GetAllPeers(ctx context.Context, accountID, userID string) } // GetAllPeers indicates an expected call of GetAllPeers. -func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeers", reflect.TypeOf((*MockManager)(nil).GetAllPeers), ctx, accountID, userID) } @@ -93,7 +99,7 @@ func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID str } // GetPeer indicates an expected call of GetPeer. -func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, userID, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, userID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeer", reflect.TypeOf((*MockManager)(nil).GetPeer), ctx, accountID, userID, peerID) } @@ -108,7 +114,7 @@ func (m *MockManager) GetPeerAccountID(ctx context.Context, peerID string) (stri } // GetPeerAccountID indicates an expected call of GetPeerAccountID. -func (mr *MockManagerMockRecorder) GetPeerAccountID(ctx, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeerAccountID(ctx, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerAccountID", reflect.TypeOf((*MockManager)(nil).GetPeerAccountID), ctx, peerID) } @@ -123,7 +129,7 @@ func (m *MockManager) GetPeerByTunnelIP(ctx context.Context, accountID string, i } // GetPeerByTunnelIP indicates an expected call of GetPeerByTunnelIP. -func (mr *MockManagerMockRecorder) GetPeerByTunnelIP(ctx, accountID, ip interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeerByTunnelIP(ctx, accountID, ip any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByTunnelIP", reflect.TypeOf((*MockManager)(nil).GetPeerByTunnelIP), ctx, accountID, ip) } @@ -138,7 +144,7 @@ func (m *MockManager) GetPeerID(ctx context.Context, peerKey string) (string, er } // GetPeerID indicates an expected call of GetPeerID. -func (mr *MockManagerMockRecorder) GetPeerID(ctx, peerKey interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeerID(ctx, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerID", reflect.TypeOf((*MockManager)(nil).GetPeerID), ctx, peerKey) } @@ -154,7 +160,7 @@ func (m *MockManager) GetPeerWithGroups(ctx context.Context, accountID, peerID s } // GetPeerWithGroups indicates an expected call of GetPeerWithGroups. -func (mr *MockManagerMockRecorder) GetPeerWithGroups(ctx, accountID, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeerWithGroups(ctx, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerWithGroups", reflect.TypeOf((*MockManager)(nil).GetPeerWithGroups), ctx, accountID, peerID) } @@ -169,7 +175,7 @@ func (m *MockManager) GetPeersByGroupIDs(ctx context.Context, accountID string, } // GetPeersByGroupIDs indicates an expected call of GetPeersByGroupIDs. -func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs) } @@ -181,7 +187,7 @@ func (m *MockManager) SetAccountManager(accountManager account.Manager) { } // SetAccountManager indicates an expected call of SetAccountManager. -func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SetAccountManager(accountManager any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccountManager", reflect.TypeOf((*MockManager)(nil).SetAccountManager), accountManager) } @@ -193,7 +199,7 @@ func (m *MockManager) SetIntegratedPeerValidator(integratedPeerValidator integra } // SetIntegratedPeerValidator indicates an expected call of SetIntegratedPeerValidator. -func (mr *MockManagerMockRecorder) SetIntegratedPeerValidator(integratedPeerValidator interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SetIntegratedPeerValidator(integratedPeerValidator any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetIntegratedPeerValidator", reflect.TypeOf((*MockManager)(nil).SetIntegratedPeerValidator), integratedPeerValidator) } @@ -205,7 +211,7 @@ func (m *MockManager) SetNetworkMapController(networkMapController network_map.C } // SetNetworkMapController indicates an expected call of SetNetworkMapController. -func (mr *MockManagerMockRecorder) SetNetworkMapController(networkMapController interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SetNetworkMapController(networkMapController any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNetworkMapController", reflect.TypeOf((*MockManager)(nil).SetNetworkMapController), networkMapController) } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go index 11bf60829..8e941d7e5 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index 22f1007ec..26214c11b 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -1,6 +1,6 @@ package proxy -//go:generate go run github.com/golang/mock/mockgen -package proxy -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +//go:generate go tool mockgen -package proxy -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod import ( "context" diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index d2be46c9f..36d6f53fc 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./manager.go +// +// Generated by this command: +// +// mockgen -package proxy -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +// // Package proxy is a generated GoMock package. package proxy @@ -9,14 +14,15 @@ import ( reflect "reflect" time "time" - gomock "github.com/golang/mock/gomock" proto "github.com/netbirdio/netbird/shared/management/proto" + gomock "go.uber.org/mock/gomock" ) // MockManager is a mock of Manager interface. type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. @@ -45,25 +51,11 @@ func (m *MockManager) CleanupStale(ctx context.Context, inactivityDuration time. } // CleanupStale indicates an expected call of CleanupStale. -func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration) } -// ClusterSupportsCustomPorts mocks base method. -func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr) - ret0, _ := ret[0].(*bool) - return ret0 -} - -// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts. -func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr) -} - // ClusterRequireSubdomain mocks base method. func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { m.ctrl.T.Helper() @@ -73,7 +65,7 @@ func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr s } // ClusterRequireSubdomain indicates an expected call of ClusterRequireSubdomain. -func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockManager)(nil).ClusterRequireSubdomain), ctx, clusterAddr) } @@ -87,11 +79,25 @@ func (m *MockManager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr s } // ClusterSupportsCrowdSec indicates an expected call of ClusterSupportsCrowdSec. -func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr) } +// ClusterSupportsCustomPorts mocks base method. +func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts. +func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr) +} + // ClusterSupportsPrivate mocks base method. func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool { m.ctrl.T.Helper() @@ -101,7 +107,7 @@ func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr st } // ClusterSupportsPrivate indicates an expected call of ClusterSupportsPrivate. -func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr) } @@ -116,11 +122,40 @@ func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAd } // Connect indicates an expected call of Connect. -func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities) } +// CountAccountProxies mocks base method. +func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountAccountProxies indicates an expected call of CountAccountProxies. +func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID) +} + +// DeleteAccountCluster mocks base method. +func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. +func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) +} + // Disconnect mocks base method. func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error { m.ctrl.T.Helper() @@ -130,11 +165,26 @@ func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) } // Disconnect indicates an expected call of Disconnect. -func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID) } +// GetAccountProxy mocks base method. +func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID) + ret0, _ := ret[0].(*Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountProxy indicates an expected call of GetAccountProxy. +func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID) +} + // GetActiveClusterAddresses mocks base method. func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) { m.ctrl.T.Helper() @@ -145,11 +195,12 @@ func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, } // GetActiveClusterAddresses indicates an expected call of GetActiveClusterAddresses. -func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx) } +// GetActiveClusterAddressesForAccount mocks base method. func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetActiveClusterAddressesForAccount", ctx, accountID) @@ -158,7 +209,8 @@ func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, a return ret0, ret1 } -func (mr *MockManagerMockRecorder) GetActiveClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call { +// GetActiveClusterAddressesForAccount indicates an expected call of GetActiveClusterAddressesForAccount. +func (mr *MockManagerMockRecorder) GetActiveClusterAddressesForAccount(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddressesForAccount", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddressesForAccount), ctx, accountID) } @@ -172,41 +224,11 @@ func (m *MockManager) Heartbeat(ctx context.Context, p *Proxy) error { } // Heartbeat indicates an expected call of Heartbeat. -func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Heartbeat(ctx, p any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p) } -// GetAccountProxy mocks base method. -func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID) - ret0, _ := ret[0].(*Proxy) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountProxy indicates an expected call of GetAccountProxy. -func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID) -} - -// CountAccountProxies mocks base method. -func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CountAccountProxies indicates an expected call of CountAccountProxies. -func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID) -} - // IsClusterAddressAvailable mocks base method. func (m *MockManager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) { m.ctrl.T.Helper() @@ -217,29 +239,16 @@ func (m *MockManager) IsClusterAddressAvailable(ctx context.Context, clusterAddr } // IsClusterAddressAvailable indicates an expected call of IsClusterAddressAvailable. -func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressAvailable", reflect.TypeOf((*MockManager)(nil).IsClusterAddressAvailable), ctx, clusterAddress, accountID) } -// DeleteAccountCluster mocks base method. -func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. -func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) -} - // MockController is a mock of Controller interface. type MockController struct { ctrl *gomock.Controller recorder *MockControllerMockRecorder + isgomock struct{} } // MockControllerMockRecorder is the mock recorder for MockController. @@ -282,7 +291,7 @@ func (m *MockController) GetProxiesForCluster(clusterAddr string) []string { } // GetProxiesForCluster indicates an expected call of GetProxiesForCluster. -func (mr *MockControllerMockRecorder) GetProxiesForCluster(clusterAddr interface{}) *gomock.Call { +func (mr *MockControllerMockRecorder) GetProxiesForCluster(clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxiesForCluster", reflect.TypeOf((*MockController)(nil).GetProxiesForCluster), clusterAddr) } @@ -296,7 +305,7 @@ func (m *MockController) RegisterProxyToCluster(ctx context.Context, clusterAddr } // RegisterProxyToCluster indicates an expected call of RegisterProxyToCluster. -func (mr *MockControllerMockRecorder) RegisterProxyToCluster(ctx, clusterAddr, proxyID interface{}) *gomock.Call { +func (mr *MockControllerMockRecorder) RegisterProxyToCluster(ctx, clusterAddr, proxyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterProxyToCluster", reflect.TypeOf((*MockController)(nil).RegisterProxyToCluster), ctx, clusterAddr, proxyID) } @@ -308,7 +317,7 @@ func (m *MockController) SendServiceUpdateToCluster(ctx context.Context, account } // SendServiceUpdateToCluster indicates an expected call of SendServiceUpdateToCluster. -func (mr *MockControllerMockRecorder) SendServiceUpdateToCluster(ctx, accountID, update, clusterAddr interface{}) *gomock.Call { +func (mr *MockControllerMockRecorder) SendServiceUpdateToCluster(ctx, accountID, update, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendServiceUpdateToCluster", reflect.TypeOf((*MockController)(nil).SendServiceUpdateToCluster), ctx, accountID, update, clusterAddr) } @@ -322,7 +331,7 @@ func (m *MockController) UnregisterProxyFromCluster(ctx context.Context, cluster } // UnregisterProxyFromCluster indicates an expected call of UnregisterProxyFromCluster. -func (mr *MockControllerMockRecorder) UnregisterProxyFromCluster(ctx, clusterAddr, proxyID interface{}) *gomock.Call { +func (mr *MockControllerMockRecorder) UnregisterProxyFromCluster(ctx, clusterAddr, proxyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterProxyFromCluster", reflect.TypeOf((*MockController)(nil).UnregisterProxyFromCluster), ctx, clusterAddr, proxyID) } diff --git a/management/internals/modules/reverseproxy/proxytoken/handler_test.go b/management/internals/modules/reverseproxy/proxytoken/handler_test.go index a5b5713c6..c71fe59f6 100644 --- a/management/internals/modules/reverseproxy/proxytoken/handler_test.go +++ b/management/internals/modules/reverseproxy/proxytoken/handler_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/reverseproxy/service/interface.go b/management/internals/modules/reverseproxy/service/interface.go index dddf6ae8a..10d93294a 100644 --- a/management/internals/modules/reverseproxy/service/interface.go +++ b/management/internals/modules/reverseproxy/service/interface.go @@ -1,6 +1,6 @@ package service -//go:generate go run github.com/golang/mock/mockgen -package service -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod +//go:generate go tool mockgen -package service -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod import ( "context" diff --git a/management/internals/modules/reverseproxy/service/interface_mock.go b/management/internals/modules/reverseproxy/service/interface_mock.go index 24963fe30..6b60f2af1 100644 --- a/management/internals/modules/reverseproxy/service/interface_mock.go +++ b/management/internals/modules/reverseproxy/service/interface_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./interface.go +// +// Generated by this command: +// +// mockgen -package service -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod +// // Package service is a generated GoMock package. package service @@ -8,14 +13,15 @@ import ( context "context" reflect "reflect" - gomock "github.com/golang/mock/gomock" proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + gomock "go.uber.org/mock/gomock" ) // MockManager is a mock of Manager interface. type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. @@ -45,7 +51,7 @@ func (m *MockManager) CreateService(ctx context.Context, accountID, userID strin } // CreateService indicates an expected call of CreateService. -func (mr *MockManagerMockRecorder) CreateService(ctx, accountID, userID, service interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateService(ctx, accountID, userID, service any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockManager)(nil).CreateService), ctx, accountID, userID, service) } @@ -60,7 +66,7 @@ func (m *MockManager) CreateServiceFromPeer(ctx context.Context, accountID, peer } // CreateServiceFromPeer indicates an expected call of CreateServiceFromPeer. -func (mr *MockManagerMockRecorder) CreateServiceFromPeer(ctx, accountID, peerID, req interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateServiceFromPeer(ctx, accountID, peerID, req any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateServiceFromPeer", reflect.TypeOf((*MockManager)(nil).CreateServiceFromPeer), ctx, accountID, peerID, req) } @@ -74,7 +80,7 @@ func (m *MockManager) DeleteAccountCluster(ctx context.Context, accountID, userI } // DeleteAccountCluster indicates an expected call of DeleteAccountCluster. -func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, accountID, userID, clusterAddress interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, accountID, userID, clusterAddress any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, accountID, userID, clusterAddress) } @@ -88,7 +94,7 @@ func (m *MockManager) DeleteAllServices(ctx context.Context, accountID, userID s } // DeleteAllServices indicates an expected call of DeleteAllServices. -func (mr *MockManagerMockRecorder) DeleteAllServices(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteAllServices(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllServices", reflect.TypeOf((*MockManager)(nil).DeleteAllServices), ctx, accountID, userID) } @@ -102,7 +108,7 @@ func (m *MockManager) DeleteService(ctx context.Context, accountID, userID, serv } // DeleteService indicates an expected call of DeleteService. -func (mr *MockManagerMockRecorder) DeleteService(ctx, accountID, userID, serviceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteService(ctx, accountID, userID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockManager)(nil).DeleteService), ctx, accountID, userID, serviceID) } @@ -117,7 +123,7 @@ func (m *MockManager) GetAccountServices(ctx context.Context, accountID string) } // GetAccountServices indicates an expected call of GetAccountServices. -func (mr *MockManagerMockRecorder) GetAccountServices(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountServices(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockManager)(nil).GetAccountServices), ctx, accountID) } @@ -132,7 +138,7 @@ func (m *MockManager) GetAllServices(ctx context.Context, accountID, userID stri } // GetAllServices indicates an expected call of GetAllServices. -func (mr *MockManagerMockRecorder) GetAllServices(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAllServices(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllServices", reflect.TypeOf((*MockManager)(nil).GetAllServices), ctx, accountID, userID) } @@ -147,7 +153,7 @@ func (m *MockManager) GetClusters(ctx context.Context, accountID, userID string) } // GetClusters indicates an expected call of GetClusters. -func (mr *MockManagerMockRecorder) GetClusters(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetClusters(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusters", reflect.TypeOf((*MockManager)(nil).GetClusters), ctx, accountID, userID) } @@ -162,7 +168,7 @@ func (m *MockManager) GetGlobalServices(ctx context.Context) ([]*Service, error) } // GetGlobalServices indicates an expected call of GetGlobalServices. -func (mr *MockManagerMockRecorder) GetGlobalServices(ctx interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetGlobalServices(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalServices", reflect.TypeOf((*MockManager)(nil).GetGlobalServices), ctx) } @@ -177,7 +183,7 @@ func (m *MockManager) GetService(ctx context.Context, accountID, userID, service } // GetService indicates an expected call of GetService. -func (mr *MockManagerMockRecorder) GetService(ctx, accountID, userID, serviceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetService(ctx, accountID, userID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetService", reflect.TypeOf((*MockManager)(nil).GetService), ctx, accountID, userID, serviceID) } @@ -192,7 +198,7 @@ func (m *MockManager) GetServiceByDomain(ctx context.Context, domain string) (*S } // GetServiceByDomain indicates an expected call of GetServiceByDomain. -func (mr *MockManagerMockRecorder) GetServiceByDomain(ctx, domain interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetServiceByDomain(ctx, domain any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByDomain", reflect.TypeOf((*MockManager)(nil).GetServiceByDomain), ctx, domain) } @@ -207,7 +213,7 @@ func (m *MockManager) GetServiceByID(ctx context.Context, accountID, serviceID s } // GetServiceByID indicates an expected call of GetServiceByID. -func (mr *MockManagerMockRecorder) GetServiceByID(ctx, accountID, serviceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetServiceByID(ctx, accountID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByID", reflect.TypeOf((*MockManager)(nil).GetServiceByID), ctx, accountID, serviceID) } @@ -222,7 +228,7 @@ func (m *MockManager) GetServiceIDByTargetID(ctx context.Context, accountID, res } // GetServiceIDByTargetID indicates an expected call of GetServiceIDByTargetID. -func (mr *MockManagerMockRecorder) GetServiceIDByTargetID(ctx, accountID, resourceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetServiceIDByTargetID(ctx, accountID, resourceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceIDByTargetID", reflect.TypeOf((*MockManager)(nil).GetServiceIDByTargetID), ctx, accountID, resourceID) } @@ -236,7 +242,7 @@ func (m *MockManager) ReloadAllServicesForAccount(ctx context.Context, accountID } // ReloadAllServicesForAccount indicates an expected call of ReloadAllServicesForAccount. -func (mr *MockManagerMockRecorder) ReloadAllServicesForAccount(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ReloadAllServicesForAccount(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadAllServicesForAccount", reflect.TypeOf((*MockManager)(nil).ReloadAllServicesForAccount), ctx, accountID) } @@ -250,7 +256,7 @@ func (m *MockManager) ReloadService(ctx context.Context, accountID, serviceID st } // ReloadService indicates an expected call of ReloadService. -func (mr *MockManagerMockRecorder) ReloadService(ctx, accountID, serviceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ReloadService(ctx, accountID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadService", reflect.TypeOf((*MockManager)(nil).ReloadService), ctx, accountID, serviceID) } @@ -264,7 +270,7 @@ func (m *MockManager) RenewServiceFromPeer(ctx context.Context, accountID, peerI } // RenewServiceFromPeer indicates an expected call of RenewServiceFromPeer. -func (mr *MockManagerMockRecorder) RenewServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) RenewServiceFromPeer(ctx, accountID, peerID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewServiceFromPeer", reflect.TypeOf((*MockManager)(nil).RenewServiceFromPeer), ctx, accountID, peerID, serviceID) } @@ -278,7 +284,7 @@ func (m *MockManager) SetCertificateIssuedAt(ctx context.Context, accountID, ser } // SetCertificateIssuedAt indicates an expected call of SetCertificateIssuedAt. -func (mr *MockManagerMockRecorder) SetCertificateIssuedAt(ctx, accountID, serviceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SetCertificateIssuedAt(ctx, accountID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCertificateIssuedAt", reflect.TypeOf((*MockManager)(nil).SetCertificateIssuedAt), ctx, accountID, serviceID) } @@ -292,7 +298,7 @@ func (m *MockManager) SetStatus(ctx context.Context, accountID, serviceID string } // SetStatus indicates an expected call of SetStatus. -func (mr *MockManagerMockRecorder) SetStatus(ctx, accountID, serviceID, status interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SetStatus(ctx, accountID, serviceID, status any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStatus", reflect.TypeOf((*MockManager)(nil).SetStatus), ctx, accountID, serviceID, status) } @@ -304,7 +310,7 @@ func (m *MockManager) StartExposeReaper(ctx context.Context) { } // StartExposeReaper indicates an expected call of StartExposeReaper. -func (mr *MockManagerMockRecorder) StartExposeReaper(ctx interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) StartExposeReaper(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartExposeReaper", reflect.TypeOf((*MockManager)(nil).StartExposeReaper), ctx) } @@ -318,7 +324,7 @@ func (m *MockManager) StopServiceFromPeer(ctx context.Context, accountID, peerID } // StopServiceFromPeer indicates an expected call of StopServiceFromPeer. -func (mr *MockManagerMockRecorder) StopServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) StopServiceFromPeer(ctx, accountID, peerID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopServiceFromPeer", reflect.TypeOf((*MockManager)(nil).StopServiceFromPeer), ctx, accountID, peerID, serviceID) } @@ -333,7 +339,7 @@ func (m *MockManager) UpdateService(ctx context.Context, accountID, userID strin } // UpdateService indicates an expected call of UpdateService. -func (mr *MockManagerMockRecorder) UpdateService(ctx, accountID, userID, service interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateService(ctx, accountID, userID, service any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockManager)(nil).UpdateService), ctx, accountID, userID, service) } diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go index c218291ef..a44e759c4 100644 --- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index 29a117921..10893673e 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -8,7 +8,7 @@ import ( "time" cachestore "github.com/eko/gocache/lib/v4/store" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/metric/noop" diff --git a/management/internals/modules/zones/manager/manager_test.go b/management/internals/modules/zones/manager/manager_test.go index 29e7e8677..f6f1743ce 100644 --- a/management/internals/modules/zones/manager/manager_test.go +++ b/management/internals/modules/zones/manager/manager_test.go @@ -5,7 +5,7 @@ import ( "fmt" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/modules/zones/records/manager/manager_test.go b/management/internals/modules/zones/records/manager/manager_test.go index a5f48c4a9..e5ed26509 100644 --- a/management/internals/modules/zones/records/manager/manager_test.go +++ b/management/internals/modules/zones/records/manager/manager_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/internals/server/server_resolve_domains_test.go b/management/internals/server/server_resolve_domains_test.go index ba9eb3f74..b34369655 100644 --- a/management/internals/server/server_resolve_domains_test.go +++ b/management/internals/server/server_resolve_domains_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" diff --git a/management/internals/shared/grpc/proxy_connect_authorizer_test.go b/management/internals/shared/grpc/proxy_connect_authorizer_test.go index ff618227e..d0d196d20 100644 --- a/management/internals/shared/grpc/proxy_connect_authorizer_test.go +++ b/management/internals/shared/grpc/proxy_connect_authorizer_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" diff --git a/management/internals/shared/grpc/proxy_snapshot_test.go b/management/internals/shared/grpc/proxy_snapshot_test.go index 68d2ecfd1..8b84a849e 100644 --- a/management/internals/shared/grpc/proxy_snapshot_test.go +++ b/management/internals/shared/grpc/proxy_snapshot_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" diff --git a/management/internals/shared/grpc/sync_mappings_test.go b/management/internals/shared/grpc/sync_mappings_test.go index 97f6183bb..6db43d7c2 100644 --- a/management/internals/shared/grpc/sync_mappings_test.go +++ b/management/internals/shared/grpc/sync_mappings_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" diff --git a/management/internals/shared/grpc/token_mgr_test.go b/management/internals/shared/grpc/token_mgr_test.go index 98eb66fb5..b1be5f99a 100644 --- a/management/internals/shared/grpc/token_mgr_test.go +++ b/management/internals/shared/grpc/token_mgr_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/internals/controllers/network_map" diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 1e738c274..f4b0408cf 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -1,6 +1,6 @@ package account -//go:generate go run github.com/golang/mock/mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +//go:generate go tool mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod import ( "context" diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 274e4c683..9ac10cba0 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./manager.go +// +// Generated by this command: +// +// mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +// // Package account is a generated GoMock package. package account @@ -11,7 +16,6 @@ import ( reflect "reflect" time "time" - gomock "github.com/golang/mock/gomock" dns "github.com/netbirdio/netbird/dns" service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" activity "github.com/netbirdio/netbird/management/server/activity" @@ -25,12 +29,14 @@ import ( route "github.com/netbirdio/netbird/route" auth "github.com/netbirdio/netbird/shared/auth" domain "github.com/netbirdio/netbird/shared/management/domain" + gomock "go.uber.org/mock/gomock" ) // MockManager is a mock of Manager interface. type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. @@ -59,7 +65,7 @@ func (m *MockManager) AcceptUserInvite(ctx context.Context, token, password stri } // AcceptUserInvite indicates an expected call of AcceptUserInvite. -func (mr *MockManagerMockRecorder) AcceptUserInvite(ctx, token, password interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) AcceptUserInvite(ctx, token, password any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcceptUserInvite", reflect.TypeOf((*MockManager)(nil).AcceptUserInvite), ctx, token, password) } @@ -74,7 +80,7 @@ func (m *MockManager) AccountExists(ctx context.Context, accountID string) (bool } // AccountExists indicates an expected call of AccountExists. -func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountExists", reflect.TypeOf((*MockManager)(nil).AccountExists), ctx, accountID) } @@ -92,7 +98,7 @@ func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID s } // AddPeer indicates an expected call of AddPeer. -func (mr *MockManagerMockRecorder) AddPeer(ctx, accountID, setupKey, userID, p, temporary interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) AddPeer(ctx, accountID, setupKey, userID, p, temporary any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeer", reflect.TypeOf((*MockManager)(nil).AddPeer), ctx, accountID, setupKey, userID, p, temporary) } @@ -107,7 +113,7 @@ func (m *MockManager) ApproveUser(ctx context.Context, accountID, initiatorUserI } // ApproveUser indicates an expected call of ApproveUser. -func (mr *MockManagerMockRecorder) ApproveUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ApproveUser(ctx, accountID, initiatorUserID, targetUserID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveUser", reflect.TypeOf((*MockManager)(nil).ApproveUser), ctx, accountID, initiatorUserID, targetUserID) } @@ -119,7 +125,7 @@ func (m *MockManager) BufferUpdateAccountPeers(ctx context.Context, accountID st } // BufferUpdateAccountPeers indicates an expected call of BufferUpdateAccountPeers. -func (mr *MockManagerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, reason interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, reason any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).BufferUpdateAccountPeers), ctx, accountID, reason) } @@ -134,7 +140,7 @@ func (m *MockManager) BuildUserInfosForAccount(ctx context.Context, accountID, i } // BuildUserInfosForAccount indicates an expected call of BuildUserInfosForAccount. -func (mr *MockManagerMockRecorder) BuildUserInfosForAccount(ctx, accountID, initiatorUserID, accountUsers interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) BuildUserInfosForAccount(ctx, accountID, initiatorUserID, accountUsers any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildUserInfosForAccount", reflect.TypeOf((*MockManager)(nil).BuildUserInfosForAccount), ctx, accountID, initiatorUserID, accountUsers) } @@ -148,7 +154,7 @@ func (m *MockManager) CreateGroup(ctx context.Context, accountID, userID string, } // CreateGroup indicates an expected call of CreateGroup. -func (mr *MockManagerMockRecorder) CreateGroup(ctx, accountID, userID, group interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateGroup(ctx, accountID, userID, group any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroup", reflect.TypeOf((*MockManager)(nil).CreateGroup), ctx, accountID, userID, group) } @@ -162,24 +168,24 @@ func (m *MockManager) CreateGroups(ctx context.Context, accountID, userID string } // CreateGroups indicates an expected call of CreateGroups. -func (mr *MockManagerMockRecorder) CreateGroups(ctx, accountID, userID, newGroups interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateGroups(ctx, accountID, userID, newGroups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockManager)(nil).CreateGroups), ctx, accountID, userID, newGroups) } // CreateIdentityProvider mocks base method. -func (m *MockManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { +func (m *MockManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, arg3 *types.IdentityProvider) (*types.IdentityProvider, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateIdentityProvider", ctx, accountID, userID, idp) + ret := m.ctrl.Call(m, "CreateIdentityProvider", ctx, accountID, userID, arg3) ret0, _ := ret[0].(*types.IdentityProvider) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateIdentityProvider indicates an expected call of CreateIdentityProvider. -func (mr *MockManagerMockRecorder) CreateIdentityProvider(ctx, accountID, userID, idp interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateIdentityProvider(ctx, accountID, userID, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIdentityProvider", reflect.TypeOf((*MockManager)(nil).CreateIdentityProvider), ctx, accountID, userID, idp) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIdentityProvider", reflect.TypeOf((*MockManager)(nil).CreateIdentityProvider), ctx, accountID, userID, arg3) } // CreateNameServerGroup mocks base method. @@ -192,7 +198,7 @@ func (m *MockManager) CreateNameServerGroup(ctx context.Context, accountID, name } // CreateNameServerGroup indicates an expected call of CreateNameServerGroup. -func (mr *MockManagerMockRecorder) CreateNameServerGroup(ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateNameServerGroup(ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNameServerGroup", reflect.TypeOf((*MockManager)(nil).CreateNameServerGroup), ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled) } @@ -207,7 +213,7 @@ func (m *MockManager) CreatePAT(ctx context.Context, accountID, initiatorUserID, } // CreatePAT indicates an expected call of CreatePAT. -func (mr *MockManagerMockRecorder) CreatePAT(ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreatePAT(ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePAT", reflect.TypeOf((*MockManager)(nil).CreatePAT), ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn) } @@ -221,7 +227,7 @@ func (m *MockManager) CreatePeerJob(ctx context.Context, accountID, peerID, user } // CreatePeerJob indicates an expected call of CreatePeerJob. -func (mr *MockManagerMockRecorder) CreatePeerJob(ctx, accountID, peerID, userID, job interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreatePeerJob(ctx, accountID, peerID, userID, job any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePeerJob", reflect.TypeOf((*MockManager)(nil).CreatePeerJob), ctx, accountID, peerID, userID, job) } @@ -236,7 +242,7 @@ func (m *MockManager) CreateRoute(ctx context.Context, accountID string, prefix } // CreateRoute indicates an expected call of CreateRoute. -func (mr *MockManagerMockRecorder) CreateRoute(ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateRoute(ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRoute", reflect.TypeOf((*MockManager)(nil).CreateRoute), ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply) } @@ -251,7 +257,7 @@ func (m *MockManager) CreateSetupKey(ctx context.Context, accountID, keyName str } // CreateSetupKey indicates an expected call of CreateSetupKey. -func (mr *MockManagerMockRecorder) CreateSetupKey(ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateSetupKey(ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSetupKey", reflect.TypeOf((*MockManager)(nil).CreateSetupKey), ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels) } @@ -266,7 +272,7 @@ func (m *MockManager) CreateUser(ctx context.Context, accountID, initiatorUserID } // CreateUser indicates an expected call of CreateUser. -func (mr *MockManagerMockRecorder) CreateUser(ctx, accountID, initiatorUserID, key interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateUser(ctx, accountID, initiatorUserID, key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockManager)(nil).CreateUser), ctx, accountID, initiatorUserID, key) } @@ -281,7 +287,7 @@ func (m *MockManager) CreateUserInvite(ctx context.Context, accountID, initiator } // CreateUserInvite indicates an expected call of CreateUserInvite. -func (mr *MockManagerMockRecorder) CreateUserInvite(ctx, accountID, initiatorUserID, invite, expiresIn interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) CreateUserInvite(ctx, accountID, initiatorUserID, invite, expiresIn any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserInvite", reflect.TypeOf((*MockManager)(nil).CreateUserInvite), ctx, accountID, initiatorUserID, invite, expiresIn) } @@ -295,7 +301,7 @@ func (m *MockManager) DeleteAccount(ctx context.Context, accountID, userID strin } // DeleteAccount indicates an expected call of DeleteAccount. -func (mr *MockManagerMockRecorder) DeleteAccount(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteAccount(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccount", reflect.TypeOf((*MockManager)(nil).DeleteAccount), ctx, accountID, userID) } @@ -309,7 +315,7 @@ func (m *MockManager) DeleteGroup(ctx context.Context, accountId, userId, groupI } // DeleteGroup indicates an expected call of DeleteGroup. -func (mr *MockManagerMockRecorder) DeleteGroup(ctx, accountId, userId, groupID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteGroup(ctx, accountId, userId, groupID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockManager)(nil).DeleteGroup), ctx, accountId, userId, groupID) } @@ -323,7 +329,7 @@ func (m *MockManager) DeleteGroups(ctx context.Context, accountId, userId string } // DeleteGroups indicates an expected call of DeleteGroups. -func (mr *MockManagerMockRecorder) DeleteGroups(ctx, accountId, userId, groupIDs interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteGroups(ctx, accountId, userId, groupIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroups", reflect.TypeOf((*MockManager)(nil).DeleteGroups), ctx, accountId, userId, groupIDs) } @@ -337,7 +343,7 @@ func (m *MockManager) DeleteIdentityProvider(ctx context.Context, accountID, idp } // DeleteIdentityProvider indicates an expected call of DeleteIdentityProvider. -func (mr *MockManagerMockRecorder) DeleteIdentityProvider(ctx, accountID, idpID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteIdentityProvider(ctx, accountID, idpID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteIdentityProvider", reflect.TypeOf((*MockManager)(nil).DeleteIdentityProvider), ctx, accountID, idpID, userID) } @@ -351,7 +357,7 @@ func (m *MockManager) DeleteNameServerGroup(ctx context.Context, accountID, nsGr } // DeleteNameServerGroup indicates an expected call of DeleteNameServerGroup. -func (mr *MockManagerMockRecorder) DeleteNameServerGroup(ctx, accountID, nsGroupID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteNameServerGroup(ctx, accountID, nsGroupID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNameServerGroup", reflect.TypeOf((*MockManager)(nil).DeleteNameServerGroup), ctx, accountID, nsGroupID, userID) } @@ -365,7 +371,7 @@ func (m *MockManager) DeletePAT(ctx context.Context, accountID, initiatorUserID, } // DeletePAT indicates an expected call of DeletePAT. -func (mr *MockManagerMockRecorder) DeletePAT(ctx, accountID, initiatorUserID, targetUserID, tokenID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeletePAT(ctx, accountID, initiatorUserID, targetUserID, tokenID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePAT", reflect.TypeOf((*MockManager)(nil).DeletePAT), ctx, accountID, initiatorUserID, targetUserID, tokenID) } @@ -379,7 +385,7 @@ func (m *MockManager) DeletePeer(ctx context.Context, accountID, peerID, userID } // DeletePeer indicates an expected call of DeletePeer. -func (mr *MockManagerMockRecorder) DeletePeer(ctx, accountID, peerID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeletePeer(ctx, accountID, peerID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeer", reflect.TypeOf((*MockManager)(nil).DeletePeer), ctx, accountID, peerID, userID) } @@ -393,7 +399,7 @@ func (m *MockManager) DeletePolicy(ctx context.Context, accountID, policyID, use } // DeletePolicy indicates an expected call of DeletePolicy. -func (mr *MockManagerMockRecorder) DeletePolicy(ctx, accountID, policyID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeletePolicy(ctx, accountID, policyID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePolicy", reflect.TypeOf((*MockManager)(nil).DeletePolicy), ctx, accountID, policyID, userID) } @@ -407,7 +413,7 @@ func (m *MockManager) DeletePostureChecks(ctx context.Context, accountID, postur } // DeletePostureChecks indicates an expected call of DeletePostureChecks. -func (mr *MockManagerMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockManager)(nil).DeletePostureChecks), ctx, accountID, postureChecksID, userID) } @@ -421,7 +427,7 @@ func (m *MockManager) DeleteRegularUsers(ctx context.Context, accountID, initiat } // DeleteRegularUsers indicates an expected call of DeleteRegularUsers. -func (mr *MockManagerMockRecorder) DeleteRegularUsers(ctx, accountID, initiatorUserID, targetUserIDs, userInfos interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteRegularUsers(ctx, accountID, initiatorUserID, targetUserIDs, userInfos any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRegularUsers", reflect.TypeOf((*MockManager)(nil).DeleteRegularUsers), ctx, accountID, initiatorUserID, targetUserIDs, userInfos) } @@ -435,7 +441,7 @@ func (m *MockManager) DeleteRoute(ctx context.Context, accountID string, routeID } // DeleteRoute indicates an expected call of DeleteRoute. -func (mr *MockManagerMockRecorder) DeleteRoute(ctx, accountID, routeID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteRoute(ctx, accountID, routeID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRoute", reflect.TypeOf((*MockManager)(nil).DeleteRoute), ctx, accountID, routeID, userID) } @@ -449,7 +455,7 @@ func (m *MockManager) DeleteSetupKey(ctx context.Context, accountID, userID, key } // DeleteSetupKey indicates an expected call of DeleteSetupKey. -func (mr *MockManagerMockRecorder) DeleteSetupKey(ctx, accountID, userID, keyID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteSetupKey(ctx, accountID, userID, keyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSetupKey", reflect.TypeOf((*MockManager)(nil).DeleteSetupKey), ctx, accountID, userID, keyID) } @@ -463,7 +469,7 @@ func (m *MockManager) DeleteUser(ctx context.Context, accountID, initiatorUserID } // DeleteUser indicates an expected call of DeleteUser. -func (mr *MockManagerMockRecorder) DeleteUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteUser(ctx, accountID, initiatorUserID, targetUserID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockManager)(nil).DeleteUser), ctx, accountID, initiatorUserID, targetUserID) } @@ -477,11 +483,38 @@ func (m *MockManager) DeleteUserInvite(ctx context.Context, accountID, initiator } // DeleteUserInvite indicates an expected call of DeleteUserInvite. -func (mr *MockManagerMockRecorder) DeleteUserInvite(ctx, accountID, initiatorUserID, inviteID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) DeleteUserInvite(ctx, accountID, initiatorUserID, inviteID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserInvite", reflect.TypeOf((*MockManager)(nil).DeleteUserInvite), ctx, accountID, initiatorUserID, inviteID) } +// ExpandAndUpdateAffected mocks base method. +func (m *MockManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ExpandAndUpdateAffected", ctx, accountID, snap, change) +} + +// ExpandAndUpdateAffected indicates an expected call of ExpandAndUpdateAffected. +func (mr *MockManagerMockRecorder) ExpandAndUpdateAffected(ctx, accountID, snap, change any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpandAndUpdateAffected", reflect.TypeOf((*MockManager)(nil).ExpandAndUpdateAffected), ctx, accountID, snap, change) +} + +// ExtendPeerSession mocks base method. +func (m *MockManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExtendPeerSession", ctx, peerPubKey, userID) + ret0, _ := ret[0].(time.Time) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExtendPeerSession indicates an expected call of ExtendPeerSession. +func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendPeerSession", reflect.TypeOf((*MockManager)(nil).ExtendPeerSession), ctx, peerPubKey, userID) +} + // FindExistingPostureCheck mocks base method. func (m *MockManager) FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { m.ctrl.T.Helper() @@ -492,7 +525,7 @@ func (m *MockManager) FindExistingPostureCheck(accountID string, checks *posture } // FindExistingPostureCheck indicates an expected call of FindExistingPostureCheck. -func (mr *MockManagerMockRecorder) FindExistingPostureCheck(accountID, checks interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) FindExistingPostureCheck(accountID, checks any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindExistingPostureCheck", reflect.TypeOf((*MockManager)(nil).FindExistingPostureCheck), accountID, checks) } @@ -507,7 +540,7 @@ func (m *MockManager) GetAccount(ctx context.Context, accountID string) (*types. } // GetAccount indicates an expected call of GetAccount. -func (mr *MockManagerMockRecorder) GetAccount(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccount(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockManager)(nil).GetAccount), ctx, accountID) } @@ -522,7 +555,7 @@ func (m *MockManager) GetAccountByID(ctx context.Context, accountID, userID stri } // GetAccountByID indicates an expected call of GetAccountByID. -func (mr *MockManagerMockRecorder) GetAccountByID(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountByID(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByID", reflect.TypeOf((*MockManager)(nil).GetAccountByID), ctx, accountID, userID) } @@ -537,7 +570,7 @@ func (m *MockManager) GetAccountIDByUserID(ctx context.Context, userAuth auth.Us } // GetAccountIDByUserID indicates an expected call of GetAccountIDByUserID. -func (mr *MockManagerMockRecorder) GetAccountIDByUserID(ctx, userAuth interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountIDByUserID(ctx, userAuth any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByUserID", reflect.TypeOf((*MockManager)(nil).GetAccountIDByUserID), ctx, userAuth) } @@ -552,7 +585,7 @@ func (m *MockManager) GetAccountIDForPeerKey(ctx context.Context, peerKey string } // GetAccountIDForPeerKey indicates an expected call of GetAccountIDForPeerKey. -func (mr *MockManagerMockRecorder) GetAccountIDForPeerKey(ctx, peerKey interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountIDForPeerKey(ctx, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDForPeerKey", reflect.TypeOf((*MockManager)(nil).GetAccountIDForPeerKey), ctx, peerKey) } @@ -568,7 +601,7 @@ func (m *MockManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth aut } // GetAccountIDFromUserAuth indicates an expected call of GetAccountIDFromUserAuth. -func (mr *MockManagerMockRecorder) GetAccountIDFromUserAuth(ctx, userAuth interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountIDFromUserAuth(ctx, userAuth any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDFromUserAuth", reflect.TypeOf((*MockManager)(nil).GetAccountIDFromUserAuth), ctx, userAuth) } @@ -583,7 +616,7 @@ func (m *MockManager) GetAccountMeta(ctx context.Context, accountID, userID stri } // GetAccountMeta indicates an expected call of GetAccountMeta. -func (mr *MockManagerMockRecorder) GetAccountMeta(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountMeta(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountMeta", reflect.TypeOf((*MockManager)(nil).GetAccountMeta), ctx, accountID, userID) } @@ -598,7 +631,7 @@ func (m *MockManager) GetAccountOnboarding(ctx context.Context, accountID, userI } // GetAccountOnboarding indicates an expected call of GetAccountOnboarding. -func (mr *MockManagerMockRecorder) GetAccountOnboarding(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountOnboarding(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOnboarding", reflect.TypeOf((*MockManager)(nil).GetAccountOnboarding), ctx, accountID, userID) } @@ -613,7 +646,7 @@ func (m *MockManager) GetAccountSettings(ctx context.Context, accountID, userID } // GetAccountSettings indicates an expected call of GetAccountSettings. -func (mr *MockManagerMockRecorder) GetAccountSettings(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAccountSettings(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSettings", reflect.TypeOf((*MockManager)(nil).GetAccountSettings), ctx, accountID, userID) } @@ -628,7 +661,7 @@ func (m *MockManager) GetAllGroups(ctx context.Context, accountID, userID string } // GetAllGroups indicates an expected call of GetAllGroups. -func (mr *MockManagerMockRecorder) GetAllGroups(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAllGroups(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllGroups", reflect.TypeOf((*MockManager)(nil).GetAllGroups), ctx, accountID, userID) } @@ -643,7 +676,7 @@ func (m *MockManager) GetAllPATs(ctx context.Context, accountID, initiatorUserID } // GetAllPATs indicates an expected call of GetAllPATs. -func (mr *MockManagerMockRecorder) GetAllPATs(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAllPATs(ctx, accountID, initiatorUserID, targetUserID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPATs", reflect.TypeOf((*MockManager)(nil).GetAllPATs), ctx, accountID, initiatorUserID, targetUserID) } @@ -658,7 +691,7 @@ func (m *MockManager) GetAllPeerJobs(ctx context.Context, accountID, userID, pee } // GetAllPeerJobs indicates an expected call of GetAllPeerJobs. -func (mr *MockManagerMockRecorder) GetAllPeerJobs(ctx, accountID, userID, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetAllPeerJobs(ctx, accountID, userID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeerJobs", reflect.TypeOf((*MockManager)(nil).GetAllPeerJobs), ctx, accountID, userID, peerID) } @@ -673,7 +706,7 @@ func (m *MockManager) GetCurrentUserInfo(ctx context.Context, userAuth auth.User } // GetCurrentUserInfo indicates an expected call of GetCurrentUserInfo. -func (mr *MockManagerMockRecorder) GetCurrentUserInfo(ctx, userAuth interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetCurrentUserInfo(ctx, userAuth any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentUserInfo", reflect.TypeOf((*MockManager)(nil).GetCurrentUserInfo), ctx, userAuth) } @@ -688,7 +721,7 @@ func (m *MockManager) GetDNSSettings(ctx context.Context, accountID, userID stri } // GetDNSSettings indicates an expected call of GetDNSSettings. -func (mr *MockManagerMockRecorder) GetDNSSettings(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetDNSSettings(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSSettings", reflect.TypeOf((*MockManager)(nil).GetDNSSettings), ctx, accountID, userID) } @@ -703,7 +736,7 @@ func (m *MockManager) GetEvents(ctx context.Context, accountID, userID string) ( } // GetEvents indicates an expected call of GetEvents. -func (mr *MockManagerMockRecorder) GetEvents(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetEvents(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvents", reflect.TypeOf((*MockManager)(nil).GetEvents), ctx, accountID, userID) } @@ -732,7 +765,7 @@ func (m *MockManager) GetGroup(ctx context.Context, accountId, groupID, userID s } // GetGroup indicates an expected call of GetGroup. -func (mr *MockManagerMockRecorder) GetGroup(ctx, accountId, groupID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetGroup(ctx, accountId, groupID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockManager)(nil).GetGroup), ctx, accountId, groupID, userID) } @@ -747,7 +780,7 @@ func (m *MockManager) GetGroupByName(ctx context.Context, groupName, accountID, } // GetGroupByName indicates an expected call of GetGroupByName. -func (mr *MockManagerMockRecorder) GetGroupByName(ctx, groupName, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetGroupByName(ctx, groupName, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockManager)(nil).GetGroupByName), ctx, groupName, accountID, userID) } @@ -762,7 +795,7 @@ func (m *MockManager) GetIdentityProvider(ctx context.Context, accountID, idpID, } // GetIdentityProvider indicates an expected call of GetIdentityProvider. -func (mr *MockManagerMockRecorder) GetIdentityProvider(ctx, accountID, idpID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetIdentityProvider(ctx, accountID, idpID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdentityProvider", reflect.TypeOf((*MockManager)(nil).GetIdentityProvider), ctx, accountID, idpID, userID) } @@ -777,7 +810,7 @@ func (m *MockManager) GetIdentityProviders(ctx context.Context, accountID, userI } // GetIdentityProviders indicates an expected call of GetIdentityProviders. -func (mr *MockManagerMockRecorder) GetIdentityProviders(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetIdentityProviders(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdentityProviders", reflect.TypeOf((*MockManager)(nil).GetIdentityProviders), ctx, accountID, userID) } @@ -806,7 +839,7 @@ func (m *MockManager) GetNameServerGroup(ctx context.Context, accountID, userID, } // GetNameServerGroup indicates an expected call of GetNameServerGroup. -func (mr *MockManagerMockRecorder) GetNameServerGroup(ctx, accountID, userID, nsGroupID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetNameServerGroup(ctx, accountID, userID, nsGroupID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNameServerGroup", reflect.TypeOf((*MockManager)(nil).GetNameServerGroup), ctx, accountID, userID, nsGroupID) } @@ -821,15 +854,15 @@ func (m *MockManager) GetNetworkMap(ctx context.Context, peerID string) (*types. } // GetNetworkMap indicates an expected call of GetNetworkMap. -func (mr *MockManagerMockRecorder) GetNetworkMap(ctx, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkMap", reflect.TypeOf((*MockManager)(nil).GetNetworkMap), ctx, peerID) } // GetOrCreateAccountByPrivateDomain mocks base method. -func (m *MockManager) GetOrCreateAccountByPrivateDomain(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error) { +func (m *MockManager) GetOrCreateAccountByPrivateDomain(ctx context.Context, initiatorId, arg2 string) (*types.Account, bool, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetOrCreateAccountByPrivateDomain", ctx, initiatorId, domain) + ret := m.ctrl.Call(m, "GetOrCreateAccountByPrivateDomain", ctx, initiatorId, arg2) ret0, _ := ret[0].(*types.Account) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) @@ -837,9 +870,9 @@ func (m *MockManager) GetOrCreateAccountByPrivateDomain(ctx context.Context, ini } // GetOrCreateAccountByPrivateDomain indicates an expected call of GetOrCreateAccountByPrivateDomain. -func (mr *MockManagerMockRecorder) GetOrCreateAccountByPrivateDomain(ctx, initiatorId, domain interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetOrCreateAccountByPrivateDomain(ctx, initiatorId, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateAccountByPrivateDomain", reflect.TypeOf((*MockManager)(nil).GetOrCreateAccountByPrivateDomain), ctx, initiatorId, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateAccountByPrivateDomain", reflect.TypeOf((*MockManager)(nil).GetOrCreateAccountByPrivateDomain), ctx, initiatorId, arg2) } // GetOrCreateAccountByUser mocks base method. @@ -852,7 +885,7 @@ func (m *MockManager) GetOrCreateAccountByUser(ctx context.Context, userAuth aut } // GetOrCreateAccountByUser indicates an expected call of GetOrCreateAccountByUser. -func (mr *MockManagerMockRecorder) GetOrCreateAccountByUser(ctx, userAuth interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetOrCreateAccountByUser(ctx, userAuth any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateAccountByUser", reflect.TypeOf((*MockManager)(nil).GetOrCreateAccountByUser), ctx, userAuth) } @@ -867,7 +900,7 @@ func (m *MockManager) GetOwnerInfo(ctx context.Context, accountId string) (*type } // GetOwnerInfo indicates an expected call of GetOwnerInfo. -func (mr *MockManagerMockRecorder) GetOwnerInfo(ctx, accountId interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetOwnerInfo(ctx, accountId any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOwnerInfo", reflect.TypeOf((*MockManager)(nil).GetOwnerInfo), ctx, accountId) } @@ -882,7 +915,7 @@ func (m *MockManager) GetPAT(ctx context.Context, accountID, initiatorUserID, ta } // GetPAT indicates an expected call of GetPAT. -func (mr *MockManagerMockRecorder) GetPAT(ctx, accountID, initiatorUserID, targetUserID, tokenID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPAT(ctx, accountID, initiatorUserID, targetUserID, tokenID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPAT", reflect.TypeOf((*MockManager)(nil).GetPAT), ctx, accountID, initiatorUserID, targetUserID, tokenID) } @@ -897,7 +930,7 @@ func (m *MockManager) GetPeer(ctx context.Context, accountID, peerID, userID str } // GetPeer indicates an expected call of GetPeer. -func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, peerID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, peerID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeer", reflect.TypeOf((*MockManager)(nil).GetPeer), ctx, accountID, peerID, userID) } @@ -912,7 +945,7 @@ func (m *MockManager) GetPeerGroups(ctx context.Context, accountID, peerID strin } // GetPeerGroups indicates an expected call of GetPeerGroups. -func (mr *MockManagerMockRecorder) GetPeerGroups(ctx, accountID, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeerGroups(ctx, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroups", reflect.TypeOf((*MockManager)(nil).GetPeerGroups), ctx, accountID, peerID) } @@ -927,7 +960,7 @@ func (m *MockManager) GetPeerJobByID(ctx context.Context, accountID, userID, pee } // GetPeerJobByID indicates an expected call of GetPeerJobByID. -func (mr *MockManagerMockRecorder) GetPeerJobByID(ctx, accountID, userID, peerID, jobID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeerJobByID(ctx, accountID, userID, peerID, jobID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobByID", reflect.TypeOf((*MockManager)(nil).GetPeerJobByID), ctx, accountID, userID, peerID, jobID) } @@ -942,7 +975,7 @@ func (m *MockManager) GetPeerNetwork(ctx context.Context, peerID string) (*types } // GetPeerNetwork indicates an expected call of GetPeerNetwork. -func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerNetwork", reflect.TypeOf((*MockManager)(nil).GetPeerNetwork), ctx, peerID) } @@ -957,7 +990,7 @@ func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilte } // GetPeers indicates an expected call of GetPeers. -func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter) } @@ -972,7 +1005,7 @@ func (m *MockManager) GetPolicy(ctx context.Context, accountID, policyID, userID } // GetPolicy indicates an expected call of GetPolicy. -func (mr *MockManagerMockRecorder) GetPolicy(ctx, accountID, policyID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPolicy(ctx, accountID, policyID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicy", reflect.TypeOf((*MockManager)(nil).GetPolicy), ctx, accountID, policyID, userID) } @@ -987,7 +1020,7 @@ func (m *MockManager) GetPostureChecks(ctx context.Context, accountID, postureCh } // GetPostureChecks indicates an expected call of GetPostureChecks. -func (mr *MockManagerMockRecorder) GetPostureChecks(ctx, accountID, postureChecksID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPostureChecks(ctx, accountID, postureChecksID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecks", reflect.TypeOf((*MockManager)(nil).GetPostureChecks), ctx, accountID, postureChecksID, userID) } @@ -1002,7 +1035,7 @@ func (m *MockManager) GetRoute(ctx context.Context, accountID string, routeID ro } // GetRoute indicates an expected call of GetRoute. -func (mr *MockManagerMockRecorder) GetRoute(ctx, accountID, routeID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetRoute(ctx, accountID, routeID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoute", reflect.TypeOf((*MockManager)(nil).GetRoute), ctx, accountID, routeID, userID) } @@ -1017,7 +1050,7 @@ func (m *MockManager) GetSetupKey(ctx context.Context, accountID, userID, keyID } // GetSetupKey indicates an expected call of GetSetupKey. -func (mr *MockManagerMockRecorder) GetSetupKey(ctx, accountID, userID, keyID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetSetupKey(ctx, accountID, userID, keyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKey", reflect.TypeOf((*MockManager)(nil).GetSetupKey), ctx, accountID, userID, keyID) } @@ -1046,7 +1079,7 @@ func (m *MockManager) GetUserByID(ctx context.Context, id string) (*types.User, } // GetUserByID indicates an expected call of GetUserByID. -func (mr *MockManagerMockRecorder) GetUserByID(ctx, id interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetUserByID(ctx, id any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByID", reflect.TypeOf((*MockManager)(nil).GetUserByID), ctx, id) } @@ -1061,7 +1094,7 @@ func (m *MockManager) GetUserFromUserAuth(ctx context.Context, userAuth auth.Use } // GetUserFromUserAuth indicates an expected call of GetUserFromUserAuth. -func (mr *MockManagerMockRecorder) GetUserFromUserAuth(ctx, userAuth interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetUserFromUserAuth(ctx, userAuth any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserFromUserAuth", reflect.TypeOf((*MockManager)(nil).GetUserFromUserAuth), ctx, userAuth) } @@ -1076,7 +1109,7 @@ func (m *MockManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (s } // GetUserIDByPeerKey indicates an expected call of GetUserIDByPeerKey. -func (mr *MockManagerMockRecorder) GetUserIDByPeerKey(ctx, peerKey interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetUserIDByPeerKey(ctx, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserIDByPeerKey", reflect.TypeOf((*MockManager)(nil).GetUserIDByPeerKey), ctx, peerKey) } @@ -1091,7 +1124,7 @@ func (m *MockManager) GetUserInviteInfo(ctx context.Context, token string) (*typ } // GetUserInviteInfo indicates an expected call of GetUserInviteInfo. -func (mr *MockManagerMockRecorder) GetUserInviteInfo(ctx, token interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetUserInviteInfo(ctx, token any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteInfo", reflect.TypeOf((*MockManager)(nil).GetUserInviteInfo), ctx, token) } @@ -1106,7 +1139,7 @@ func (m *MockManager) GetUsersFromAccount(ctx context.Context, accountID, userID } // GetUsersFromAccount indicates an expected call of GetUsersFromAccount. -func (mr *MockManagerMockRecorder) GetUsersFromAccount(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetUsersFromAccount(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersFromAccount", reflect.TypeOf((*MockManager)(nil).GetUsersFromAccount), ctx, accountID, userID) } @@ -1122,7 +1155,7 @@ func (m *MockManager) GetValidatedPeers(ctx context.Context, accountID string) ( } // GetValidatedPeers indicates an expected call of GetValidatedPeers. -func (mr *MockManagerMockRecorder) GetValidatedPeers(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetValidatedPeers(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeers", reflect.TypeOf((*MockManager)(nil).GetValidatedPeers), ctx, accountID) } @@ -1136,7 +1169,7 @@ func (m *MockManager) GroupAddPeer(ctx context.Context, accountId, groupID, peer } // GroupAddPeer indicates an expected call of GroupAddPeer. -func (mr *MockManagerMockRecorder) GroupAddPeer(ctx, accountId, groupID, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GroupAddPeer(ctx, accountId, groupID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupAddPeer", reflect.TypeOf((*MockManager)(nil).GroupAddPeer), ctx, accountId, groupID, peerID) } @@ -1150,7 +1183,7 @@ func (m *MockManager) GroupDeletePeer(ctx context.Context, accountId, groupID, p } // GroupDeletePeer indicates an expected call of GroupDeletePeer. -func (mr *MockManagerMockRecorder) GroupDeletePeer(ctx, accountId, groupID, peerID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GroupDeletePeer(ctx, accountId, groupID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupDeletePeer", reflect.TypeOf((*MockManager)(nil).GroupDeletePeer), ctx, accountId, groupID, peerID) } @@ -1165,7 +1198,7 @@ func (m *MockManager) GroupValidation(ctx context.Context, accountId string, gro } // GroupValidation indicates an expected call of GroupValidation. -func (mr *MockManagerMockRecorder) GroupValidation(ctx, accountId, groups interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GroupValidation(ctx, accountId, groups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupValidation", reflect.TypeOf((*MockManager)(nil).GroupValidation), ctx, accountId, groups) } @@ -1179,7 +1212,7 @@ func (m *MockManager) InviteUser(ctx context.Context, accountID, initiatorUserID } // InviteUser indicates an expected call of InviteUser. -func (mr *MockManagerMockRecorder) InviteUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) InviteUser(ctx, accountID, initiatorUserID, targetUserID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteUser", reflect.TypeOf((*MockManager)(nil).InviteUser), ctx, accountID, initiatorUserID, targetUserID) } @@ -1194,7 +1227,7 @@ func (m *MockManager) ListNameServerGroups(ctx context.Context, accountID, userI } // ListNameServerGroups indicates an expected call of ListNameServerGroups. -func (mr *MockManagerMockRecorder) ListNameServerGroups(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ListNameServerGroups(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListNameServerGroups", reflect.TypeOf((*MockManager)(nil).ListNameServerGroups), ctx, accountID, userID) } @@ -1209,7 +1242,7 @@ func (m *MockManager) ListPolicies(ctx context.Context, accountID, userID string } // ListPolicies indicates an expected call of ListPolicies. -func (mr *MockManagerMockRecorder) ListPolicies(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ListPolicies(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPolicies", reflect.TypeOf((*MockManager)(nil).ListPolicies), ctx, accountID, userID) } @@ -1224,7 +1257,7 @@ func (m *MockManager) ListPostureChecks(ctx context.Context, accountID, userID s } // ListPostureChecks indicates an expected call of ListPostureChecks. -func (mr *MockManagerMockRecorder) ListPostureChecks(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ListPostureChecks(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPostureChecks", reflect.TypeOf((*MockManager)(nil).ListPostureChecks), ctx, accountID, userID) } @@ -1239,7 +1272,7 @@ func (m *MockManager) ListRoutes(ctx context.Context, accountID, userID string) } // ListRoutes indicates an expected call of ListRoutes. -func (mr *MockManagerMockRecorder) ListRoutes(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ListRoutes(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRoutes", reflect.TypeOf((*MockManager)(nil).ListRoutes), ctx, accountID, userID) } @@ -1254,7 +1287,7 @@ func (m *MockManager) ListSetupKeys(ctx context.Context, accountID, userID strin } // ListSetupKeys indicates an expected call of ListSetupKeys. -func (mr *MockManagerMockRecorder) ListSetupKeys(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ListSetupKeys(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSetupKeys", reflect.TypeOf((*MockManager)(nil).ListSetupKeys), ctx, accountID, userID) } @@ -1269,7 +1302,7 @@ func (m *MockManager) ListUserInvites(ctx context.Context, accountID, initiatorU } // ListUserInvites indicates an expected call of ListUserInvites. -func (mr *MockManagerMockRecorder) ListUserInvites(ctx, accountID, initiatorUserID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ListUserInvites(ctx, accountID, initiatorUserID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserInvites", reflect.TypeOf((*MockManager)(nil).ListUserInvites), ctx, accountID, initiatorUserID) } @@ -1284,7 +1317,7 @@ func (m *MockManager) ListUsers(ctx context.Context, accountID string) ([]*types } // ListUsers indicates an expected call of ListUsers. -func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUsers", reflect.TypeOf((*MockManager)(nil).ListUsers), ctx, accountID) } @@ -1302,28 +1335,13 @@ func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*pe } // LoginPeer indicates an expected call of LoginPeer. -func (mr *MockManagerMockRecorder) LoginPeer(ctx, login interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) LoginPeer(ctx, login any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoginPeer", reflect.TypeOf((*MockManager)(nil).LoginPeer), ctx, login) } -// ExtendPeerSession mocks base method. -func (m *MockManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtendPeerSession", ctx, peerPubKey, userID) - ret0, _ := ret[0].(time.Time) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ExtendPeerSession indicates an expected call of ExtendPeerSession. -func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendPeerSession", reflect.TypeOf((*MockManager)(nil).ExtendPeerSession), ctx, peerPubKey, userID) -} - // MarkPeerConnected mocks base method. -func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { +func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, accountID, sessionStartedAt, nmap) ret0, _ := ret[0].(error) @@ -1331,13 +1349,13 @@ func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, acc } // MarkPeerConnected indicates an expected call of MarkPeerConnected. -func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, accountID, sessionStartedAt, nmap interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, accountID, sessionStartedAt, nmap any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, accountID, sessionStartedAt, nmap) } // MarkPeerDisconnected mocks base method. -func (m *MockManager) MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error { +func (m *MockManager) MarkPeerDisconnected(ctx context.Context, peerKey, accountID string, sessionStartedAt int64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "MarkPeerDisconnected", ctx, peerKey, accountID, sessionStartedAt) ret0, _ := ret[0].(error) @@ -1345,7 +1363,7 @@ func (m *MockManager) MarkPeerDisconnected(ctx context.Context, peerKey string, } // MarkPeerDisconnected indicates an expected call of MarkPeerDisconnected. -func (mr *MockManagerMockRecorder) MarkPeerDisconnected(ctx, peerKey, accountID, sessionStartedAt interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) MarkPeerDisconnected(ctx, peerKey, accountID, sessionStartedAt any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnected", reflect.TypeOf((*MockManager)(nil).MarkPeerDisconnected), ctx, peerKey, accountID, sessionStartedAt) } @@ -1359,7 +1377,7 @@ func (m *MockManager) OnPeerDisconnected(ctx context.Context, accountID, peerPub } // OnPeerDisconnected indicates an expected call of OnPeerDisconnected. -func (mr *MockManagerMockRecorder) OnPeerDisconnected(ctx, accountID, peerPubKey, streamStartTime interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) OnPeerDisconnected(ctx, accountID, peerPubKey, streamStartTime any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerDisconnected", reflect.TypeOf((*MockManager)(nil).OnPeerDisconnected), ctx, accountID, peerPubKey, streamStartTime) } @@ -1374,7 +1392,7 @@ func (m *MockManager) RegenerateUserInvite(ctx context.Context, accountID, initi } // RegenerateUserInvite indicates an expected call of RegenerateUserInvite. -func (mr *MockManagerMockRecorder) RegenerateUserInvite(ctx, accountID, initiatorUserID, inviteID, expiresIn interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) RegenerateUserInvite(ctx, accountID, initiatorUserID, inviteID, expiresIn any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegenerateUserInvite", reflect.TypeOf((*MockManager)(nil).RegenerateUserInvite), ctx, accountID, initiatorUserID, inviteID, expiresIn) } @@ -1388,7 +1406,7 @@ func (m *MockManager) RejectUser(ctx context.Context, accountID, initiatorUserID } // RejectUser indicates an expected call of RejectUser. -func (mr *MockManagerMockRecorder) RejectUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) RejectUser(ctx, accountID, initiatorUserID, targetUserID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RejectUser", reflect.TypeOf((*MockManager)(nil).RejectUser), ctx, accountID, initiatorUserID, targetUserID) } @@ -1402,7 +1420,7 @@ func (m *MockManager) SaveDNSSettings(ctx context.Context, accountID, userID str } // SaveDNSSettings indicates an expected call of SaveDNSSettings. -func (mr *MockManagerMockRecorder) SaveDNSSettings(ctx, accountID, userID, dnsSettingsToSave interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SaveDNSSettings(ctx, accountID, userID, dnsSettingsToSave any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveDNSSettings", reflect.TypeOf((*MockManager)(nil).SaveDNSSettings), ctx, accountID, userID, dnsSettingsToSave) } @@ -1416,7 +1434,7 @@ func (m *MockManager) SaveNameServerGroup(ctx context.Context, accountID, userID } // SaveNameServerGroup indicates an expected call of SaveNameServerGroup. -func (mr *MockManagerMockRecorder) SaveNameServerGroup(ctx, accountID, userID, nsGroupToSave interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SaveNameServerGroup(ctx, accountID, userID, nsGroupToSave any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNameServerGroup", reflect.TypeOf((*MockManager)(nil).SaveNameServerGroup), ctx, accountID, userID, nsGroupToSave) } @@ -1431,7 +1449,7 @@ func (m *MockManager) SaveOrAddUser(ctx context.Context, accountID, initiatorUse } // SaveOrAddUser indicates an expected call of SaveOrAddUser. -func (mr *MockManagerMockRecorder) SaveOrAddUser(ctx, accountID, initiatorUserID, update, addIfNotExists interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SaveOrAddUser(ctx, accountID, initiatorUserID, update, addIfNotExists any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveOrAddUser", reflect.TypeOf((*MockManager)(nil).SaveOrAddUser), ctx, accountID, initiatorUserID, update, addIfNotExists) } @@ -1446,7 +1464,7 @@ func (m *MockManager) SaveOrAddUsers(ctx context.Context, accountID, initiatorUs } // SaveOrAddUsers indicates an expected call of SaveOrAddUsers. -func (mr *MockManagerMockRecorder) SaveOrAddUsers(ctx, accountID, initiatorUserID, updates, addIfNotExists interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SaveOrAddUsers(ctx, accountID, initiatorUserID, updates, addIfNotExists any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveOrAddUsers", reflect.TypeOf((*MockManager)(nil).SaveOrAddUsers), ctx, accountID, initiatorUserID, updates, addIfNotExists) } @@ -1461,7 +1479,7 @@ func (m *MockManager) SavePolicy(ctx context.Context, accountID, userID string, } // SavePolicy indicates an expected call of SavePolicy. -func (mr *MockManagerMockRecorder) SavePolicy(ctx, accountID, userID, policy, create interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SavePolicy(ctx, accountID, userID, policy, create any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePolicy", reflect.TypeOf((*MockManager)(nil).SavePolicy), ctx, accountID, userID, policy, create) } @@ -1476,23 +1494,23 @@ func (m *MockManager) SavePostureChecks(ctx context.Context, accountID, userID s } // SavePostureChecks indicates an expected call of SavePostureChecks. -func (mr *MockManagerMockRecorder) SavePostureChecks(ctx, accountID, userID, postureChecks, create interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SavePostureChecks(ctx, accountID, userID, postureChecks, create any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePostureChecks", reflect.TypeOf((*MockManager)(nil).SavePostureChecks), ctx, accountID, userID, postureChecks, create) } // SaveRoute mocks base method. -func (m *MockManager) SaveRoute(ctx context.Context, accountID, userID string, route *route.Route) error { +func (m *MockManager) SaveRoute(ctx context.Context, accountID, userID string, arg3 *route.Route) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveRoute", ctx, accountID, userID, route) + ret := m.ctrl.Call(m, "SaveRoute", ctx, accountID, userID, arg3) ret0, _ := ret[0].(error) return ret0 } // SaveRoute indicates an expected call of SaveRoute. -func (mr *MockManagerMockRecorder) SaveRoute(ctx, accountID, userID, route interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SaveRoute(ctx, accountID, userID, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockManager)(nil).SaveRoute), ctx, accountID, userID, route) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockManager)(nil).SaveRoute), ctx, accountID, userID, arg3) } // SaveSetupKey mocks base method. @@ -1505,7 +1523,7 @@ func (m *MockManager) SaveSetupKey(ctx context.Context, accountID string, key *t } // SaveSetupKey indicates an expected call of SaveSetupKey. -func (mr *MockManagerMockRecorder) SaveSetupKey(ctx, accountID, key, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SaveSetupKey(ctx, accountID, key, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSetupKey", reflect.TypeOf((*MockManager)(nil).SaveSetupKey), ctx, accountID, key, userID) } @@ -1520,7 +1538,7 @@ func (m *MockManager) SaveUser(ctx context.Context, accountID, initiatorUserID s } // SaveUser indicates an expected call of SaveUser. -func (mr *MockManagerMockRecorder) SaveUser(ctx, accountID, initiatorUserID, update interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SaveUser(ctx, accountID, initiatorUserID, update any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUser", reflect.TypeOf((*MockManager)(nil).SaveUser), ctx, accountID, initiatorUserID, update) } @@ -1532,7 +1550,7 @@ func (m *MockManager) SetServiceManager(serviceManager service.Manager) { } // SetServiceManager indicates an expected call of SetServiceManager. -func (mr *MockManagerMockRecorder) SetServiceManager(serviceManager interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SetServiceManager(serviceManager any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetServiceManager", reflect.TypeOf((*MockManager)(nil).SetServiceManager), serviceManager) } @@ -1544,7 +1562,7 @@ func (m *MockManager) StoreEvent(ctx context.Context, initiatorID, targetID, acc } // StoreEvent indicates an expected call of StoreEvent. -func (mr *MockManagerMockRecorder) StoreEvent(ctx, initiatorID, targetID, accountID, activityID, meta interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) StoreEvent(ctx, initiatorID, targetID, accountID, activityID, meta any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreEvent", reflect.TypeOf((*MockManager)(nil).StoreEvent), ctx, initiatorID, targetID, accountID, activityID, meta) } @@ -1562,7 +1580,7 @@ func (m *MockManager) SyncAndMarkPeer(ctx context.Context, accountID, peerPubKey } // SyncAndMarkPeer indicates an expected call of SyncAndMarkPeer. -func (mr *MockManagerMockRecorder) SyncAndMarkPeer(ctx, accountID, peerPubKey, meta, realIP, syncTime interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SyncAndMarkPeer(ctx, accountID, peerPubKey, meta, realIP, syncTime any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncAndMarkPeer", reflect.TypeOf((*MockManager)(nil).SyncAndMarkPeer), ctx, accountID, peerPubKey, meta, realIP, syncTime) } @@ -1580,7 +1598,7 @@ func (m *MockManager) SyncPeer(ctx context.Context, sync types.PeerSync, account } // SyncPeer indicates an expected call of SyncPeer. -func (mr *MockManagerMockRecorder) SyncPeer(ctx, sync, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SyncPeer(ctx, sync, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeer", reflect.TypeOf((*MockManager)(nil).SyncPeer), ctx, sync, accountID) } @@ -1594,7 +1612,7 @@ func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta } // SyncPeerMeta indicates an expected call of SyncPeerMeta. -func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta, realIP interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta, realIP any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta, realIP) } @@ -1608,7 +1626,7 @@ func (m *MockManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserA } // SyncUserJWTGroups indicates an expected call of SyncUserJWTGroups. -func (mr *MockManagerMockRecorder) SyncUserJWTGroups(ctx, userAuth interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SyncUserJWTGroups(ctx, userAuth any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncUserJWTGroups", reflect.TypeOf((*MockManager)(nil).SyncUserJWTGroups), ctx, userAuth) } @@ -1623,7 +1641,7 @@ func (m *MockManager) UpdateAccountOnboarding(ctx context.Context, accountID, us } // UpdateAccountOnboarding indicates an expected call of UpdateAccountOnboarding. -func (mr *MockManagerMockRecorder) UpdateAccountOnboarding(ctx, accountID, userID, newOnboarding interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateAccountOnboarding(ctx, accountID, userID, newOnboarding any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountOnboarding", reflect.TypeOf((*MockManager)(nil).UpdateAccountOnboarding), ctx, accountID, userID, newOnboarding) } @@ -1635,23 +1653,11 @@ func (m *MockManager) UpdateAccountPeers(ctx context.Context, accountID string, } // UpdateAccountPeers indicates an expected call of UpdateAccountPeers. -func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID, reason interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID, reason any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID, reason) } -// ExpandAndUpdateAffected mocks base method. -func (m *MockManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "ExpandAndUpdateAffected", ctx, accountID, snap, change) -} - -// ExpandAndUpdateAffected indicates an expected call of ExpandAndUpdateAffected. -func (mr *MockManagerMockRecorder) ExpandAndUpdateAffected(ctx, accountID, snap, change interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpandAndUpdateAffected", reflect.TypeOf((*MockManager)(nil).ExpandAndUpdateAffected), ctx, accountID, snap, change) -} - // UpdateAccountSettings mocks base method. func (m *MockManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) { m.ctrl.T.Helper() @@ -1662,7 +1668,7 @@ func (m *MockManager) UpdateAccountSettings(ctx context.Context, accountID, user } // UpdateAccountSettings indicates an expected call of UpdateAccountSettings. -func (mr *MockManagerMockRecorder) UpdateAccountSettings(ctx, accountID, userID, newSettings interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateAccountSettings(ctx, accountID, userID, newSettings any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountSettings", reflect.TypeOf((*MockManager)(nil).UpdateAccountSettings), ctx, accountID, userID, newSettings) } @@ -1676,7 +1682,7 @@ func (m *MockManager) UpdateGroup(ctx context.Context, accountID, userID string, } // UpdateGroup indicates an expected call of UpdateGroup. -func (mr *MockManagerMockRecorder) UpdateGroup(ctx, accountID, userID, group interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateGroup(ctx, accountID, userID, group any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroup", reflect.TypeOf((*MockManager)(nil).UpdateGroup), ctx, accountID, userID, group) } @@ -1690,24 +1696,24 @@ func (m *MockManager) UpdateGroups(ctx context.Context, accountID, userID string } // UpdateGroups indicates an expected call of UpdateGroups. -func (mr *MockManagerMockRecorder) UpdateGroups(ctx, accountID, userID, newGroups interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateGroups(ctx, accountID, userID, newGroups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockManager)(nil).UpdateGroups), ctx, accountID, userID, newGroups) } // UpdateIdentityProvider mocks base method. -func (m *MockManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { +func (m *MockManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, arg4 *types.IdentityProvider) (*types.IdentityProvider, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateIdentityProvider", ctx, accountID, idpID, userID, idp) + ret := m.ctrl.Call(m, "UpdateIdentityProvider", ctx, accountID, idpID, userID, arg4) ret0, _ := ret[0].(*types.IdentityProvider) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateIdentityProvider indicates an expected call of UpdateIdentityProvider. -func (mr *MockManagerMockRecorder) UpdateIdentityProvider(ctx, accountID, idpID, userID, idp interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateIdentityProvider(ctx, accountID, idpID, userID, arg4 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIdentityProvider", reflect.TypeOf((*MockManager)(nil).UpdateIdentityProvider), ctx, accountID, idpID, userID, idp) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIdentityProvider", reflect.TypeOf((*MockManager)(nil).UpdateIdentityProvider), ctx, accountID, idpID, userID, arg4) } // UpdateIntegratedValidator mocks base method. @@ -1719,7 +1725,7 @@ func (m *MockManager) UpdateIntegratedValidator(ctx context.Context, accountID, } // UpdateIntegratedValidator indicates an expected call of UpdateIntegratedValidator. -func (mr *MockManagerMockRecorder) UpdateIntegratedValidator(ctx, accountID, userID, validator, groups interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateIntegratedValidator(ctx, accountID, userID, validator, groups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIntegratedValidator", reflect.TypeOf((*MockManager)(nil).UpdateIntegratedValidator), ctx, accountID, userID, validator, groups) } @@ -1734,7 +1740,7 @@ func (m *MockManager) UpdatePeer(ctx context.Context, accountID, userID string, } // UpdatePeer indicates an expected call of UpdatePeer. -func (mr *MockManagerMockRecorder) UpdatePeer(ctx, accountID, userID, p interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdatePeer(ctx, accountID, userID, p any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeer", reflect.TypeOf((*MockManager)(nil).UpdatePeer), ctx, accountID, userID, p) } @@ -1748,11 +1754,12 @@ func (m *MockManager) UpdatePeerIP(ctx context.Context, accountID, userID, peerI } // UpdatePeerIP indicates an expected call of UpdatePeerIP. -func (mr *MockManagerMockRecorder) UpdatePeerIP(ctx, accountID, userID, peerID, newIP interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdatePeerIP(ctx, accountID, userID, peerID, newIP any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIP", reflect.TypeOf((*MockManager)(nil).UpdatePeerIP), ctx, accountID, userID, peerID, newIP) } +// UpdatePeerIPv6 mocks base method. func (m *MockManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdatePeerIPv6", ctx, accountID, userID, peerID, newIPv6) @@ -1760,7 +1767,8 @@ func (m *MockManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, pee return ret0 } -func (mr *MockManagerMockRecorder) UpdatePeerIPv6(ctx, accountID, userID, peerID, newIPv6 interface{}) *gomock.Call { +// UpdatePeerIPv6 indicates an expected call of UpdatePeerIPv6. +func (mr *MockManagerMockRecorder) UpdatePeerIPv6(ctx, accountID, userID, peerID, newIPv6 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIPv6", reflect.TypeOf((*MockManager)(nil).UpdatePeerIPv6), ctx, accountID, userID, peerID, newIPv6) } @@ -1774,7 +1782,7 @@ func (m *MockManager) UpdateToPrimaryAccount(ctx context.Context, accountId stri } // UpdateToPrimaryAccount indicates an expected call of UpdateToPrimaryAccount. -func (mr *MockManagerMockRecorder) UpdateToPrimaryAccount(ctx, accountId interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateToPrimaryAccount(ctx, accountId any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateToPrimaryAccount", reflect.TypeOf((*MockManager)(nil).UpdateToPrimaryAccount), ctx, accountId) } @@ -1788,7 +1796,7 @@ func (m *MockManager) UpdateUserPassword(ctx context.Context, accountID, current } // UpdateUserPassword indicates an expected call of UpdateUserPassword. -func (mr *MockManagerMockRecorder) UpdateUserPassword(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateUserPassword(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPassword", reflect.TypeOf((*MockManager)(nil).UpdateUserPassword), ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword) } diff --git a/management/server/account_test.go b/management/server/account_test.go index 73126a496..5a826e103 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/prometheus/client_golang/prometheus/push" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 8917902d9..d7667a304 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" nbdns "github.com/netbirdio/netbird/dns" diff --git a/management/server/group_test.go b/management/server/group_test.go index deeec61d5..f5aeceea8 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 0069efcb7..06419019e 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" diff --git a/management/server/http/handlers/instance/instance_handler_test.go b/management/server/http/handlers/instance/instance_handler_test.go index 711e01964..ba59497fa 100644 --- a/management/server/http/handlers/instance/instance_handler_test.go +++ b/management/server/http/handlers/instance/instance_handler_test.go @@ -10,7 +10,7 @@ import ( "net/mail" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 047213879..592d64d1a 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -13,9 +13,8 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" "github.com/gorilla/mux" - ugomock "go.uber.org/mock/gomock" + "go.uber.org/mock/gomock" "golang.org/x/exp/maps" "github.com/netbirdio/netbird/management/internals/controllers/network_map" @@ -106,7 +105,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { }, } - ctrl := ugomock.NewController(t) + ctrl := gomock.NewController(t) networkMapController := network_map.NewMockController(ctrl) networkMapController.EXPECT(). diff --git a/management/server/http/handlers/policies/geolocation_handler_test.go b/management/server/http/handlers/policies/geolocation_handler_test.go index f5723b8fc..42b98734b 100644 --- a/management/server/http/handlers/policies/geolocation_handler_test.go +++ b/management/server/http/handlers/policies/geolocation_handler_test.go @@ -10,7 +10,7 @@ import ( "path/filepath" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go index d51254c55..b55d4f24c 100644 --- a/management/server/identity_provider_test.go +++ b/management/server/identity_provider_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/server/instance/setup_service_test.go b/management/server/instance/setup_service_test.go index 12ec7d0fa..af3a91b75 100644 --- a/management/server/instance/setup_service_test.go +++ b/management/server/instance/setup_service_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 45d4ab8c9..c23ca6237 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" diff --git a/management/server/management_test.go b/management/server/management_test.go index f1d49193c..80c76f0de 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" pb "github.com/golang/protobuf/proto" //nolint log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index e13b0bb19..ce5d5d57b 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/server/networks/resources/manager_test.go b/management/server/networks/resources/manager_test.go index c6d8e7bcc..bd9dd84dd 100644 --- a/management/server/networks/resources/manager_test.go +++ b/management/server/networks/resources/manager_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" reverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" diff --git a/management/server/peer_test.go b/management/server/peer_test.go index a7f8ba695..80d270e98 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -16,7 +16,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/rs/xid" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" diff --git a/management/server/permissions/manager.go b/management/server/permissions/manager.go index 6b9977a86..90166acbd 100644 --- a/management/server/permissions/manager.go +++ b/management/server/permissions/manager.go @@ -1,6 +1,6 @@ package permissions -//go:generate go run github.com/golang/mock/mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +//go:generate go tool mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod import ( "context" diff --git a/management/server/permissions/manager_mock.go b/management/server/permissions/manager_mock.go index 934e33398..251e456d4 100644 --- a/management/server/permissions/manager_mock.go +++ b/management/server/permissions/manager_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./manager.go +// +// Generated by this command: +// +// mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +// // Package permissions is a generated GoMock package. package permissions @@ -8,18 +13,19 @@ import ( context "context" reflect "reflect" - gomock "github.com/golang/mock/gomock" account "github.com/netbirdio/netbird/management/server/account" modules "github.com/netbirdio/netbird/management/server/permissions/modules" operations "github.com/netbirdio/netbird/management/server/permissions/operations" roles "github.com/netbirdio/netbird/management/server/permissions/roles" types "github.com/netbirdio/netbird/management/server/types" + gomock "go.uber.org/mock/gomock" ) // MockManager is a mock of Manager interface. type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. @@ -49,7 +55,7 @@ func (m *MockManager) GetPermissionsByRole(ctx context.Context, role types.UserR } // GetPermissionsByRole indicates an expected call of GetPermissionsByRole. -func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPermissionsByRole", reflect.TypeOf((*MockManager)(nil).GetPermissionsByRole), ctx, role) } @@ -61,7 +67,7 @@ func (m *MockManager) SetAccountManager(accountManager account.Manager) { } // SetAccountManager indicates an expected call of SetAccountManager. -func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SetAccountManager(accountManager any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccountManager", reflect.TypeOf((*MockManager)(nil).SetAccountManager), accountManager) } @@ -76,7 +82,7 @@ func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID strin } // ValidateAccountAccess indicates an expected call of ValidateAccountAccess. -func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateAccountAccess", reflect.TypeOf((*MockManager)(nil).ValidateAccountAccess), ctx, accountID, user, allowOwnerAndAdmin) } @@ -90,7 +96,7 @@ func (m *MockManager) ValidateRoleModuleAccess(ctx context.Context, accountID st } // ValidateRoleModuleAccess indicates an expected call of ValidateRoleModuleAccess. -func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateRoleModuleAccess", reflect.TypeOf((*MockManager)(nil).ValidateRoleModuleAccess), ctx, accountID, role, module, operation) } @@ -106,7 +112,7 @@ func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, us } // ValidateUserPermissions indicates an expected call of ValidateUserPermissions. -func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateUserPermissions", reflect.TypeOf((*MockManager)(nil).ValidateUserPermissions), ctx, accountID, userID, module, operation) } diff --git a/management/server/route_test.go b/management/server/route_test.go index 5ae18c253..53dbb29d9 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/management/server/settings/manager.go b/management/server/settings/manager.go index f84739193..dc5b46471 100644 --- a/management/server/settings/manager.go +++ b/management/server/settings/manager.go @@ -1,6 +1,6 @@ package settings -//go:generate go run github.com/golang/mock/mockgen -package settings -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +//go:generate go tool mockgen -package settings -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod import ( "context" diff --git a/management/server/settings/manager_mock.go b/management/server/settings/manager_mock.go index 4bedb2cf7..59b321875 100644 --- a/management/server/settings/manager_mock.go +++ b/management/server/settings/manager_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./manager.go +// +// Generated by this command: +// +// mockgen -package settings -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod +// // Package settings is a generated GoMock package. package settings @@ -9,15 +14,16 @@ import ( netip "net/netip" reflect "reflect" - gomock "github.com/golang/mock/gomock" extra_settings "github.com/netbirdio/netbird/management/server/integrations/extra_settings" types "github.com/netbirdio/netbird/management/server/types" + gomock "go.uber.org/mock/gomock" ) // MockManager is a mock of Manager interface. type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. @@ -37,6 +43,22 @@ func (m *MockManager) EXPECT() *MockManagerMockRecorder { return m.recorder } +// GetEffectiveNetworkRanges mocks base method. +func (m *MockManager) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEffectiveNetworkRanges", ctx, accountID) + ret0, _ := ret[0].(netip.Prefix) + ret1, _ := ret[1].(netip.Prefix) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetEffectiveNetworkRanges indicates an expected call of GetEffectiveNetworkRanges. +func (mr *MockManagerMockRecorder) GetEffectiveNetworkRanges(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEffectiveNetworkRanges", reflect.TypeOf((*MockManager)(nil).GetEffectiveNetworkRanges), ctx, accountID) +} + // GetExtraSettings mocks base method. func (m *MockManager) GetExtraSettings(ctx context.Context, accountID string) (*types.ExtraSettings, error) { m.ctrl.T.Helper() @@ -47,7 +69,7 @@ func (m *MockManager) GetExtraSettings(ctx context.Context, accountID string) (* } // GetExtraSettings indicates an expected call of GetExtraSettings. -func (mr *MockManagerMockRecorder) GetExtraSettings(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetExtraSettings(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExtraSettings", reflect.TypeOf((*MockManager)(nil).GetExtraSettings), ctx, accountID) } @@ -76,7 +98,7 @@ func (m *MockManager) GetSettings(ctx context.Context, accountID, userID string) } // GetSettings indicates an expected call of GetSettings. -func (mr *MockManagerMockRecorder) GetSettings(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) GetSettings(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSettings", reflect.TypeOf((*MockManager)(nil).GetSettings), ctx, accountID, userID) } @@ -91,23 +113,7 @@ func (m *MockManager) UpdateExtraSettings(ctx context.Context, accountID, userID } // UpdateExtraSettings indicates an expected call of UpdateExtraSettings. -func (mr *MockManagerMockRecorder) UpdateExtraSettings(ctx, accountID, userID, extraSettings interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateExtraSettings(ctx, accountID, userID, extraSettings any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExtraSettings", reflect.TypeOf((*MockManager)(nil).UpdateExtraSettings), ctx, accountID, userID, extraSettings) } - -// GetEffectiveNetworkRanges mocks base method. -func (m *MockManager) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEffectiveNetworkRanges", ctx, accountID) - ret0, _ := ret[0].(netip.Prefix) - ret1, _ := ret[1].(netip.Prefix) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetEffectiveNetworkRanges indicates an expected call of GetEffectiveNetworkRanges. -func (mr *MockManagerMockRecorder) GetEffectiveNetworkRanges(ctx, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEffectiveNetworkRanges", reflect.TypeOf((*MockManager)(nil).GetEffectiveNetworkRanges), ctx, accountID) -} diff --git a/management/server/store/store.go b/management/server/store/store.go index 869e8dab5..ca911092b 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -1,6 +1,6 @@ package store -//go:generate go run github.com/golang/mock/mockgen -package store -destination=store_mock.go -source=./store.go -build_flags=-mod=mod +//go:generate go tool mockgen -package store -destination=store_mock.go -source=./store.go -build_flags=-mod=mod import ( "context" diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 3d9e160ba..70acb9f58 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./store.go +// +// Generated by this command: +// +// mockgen -package store -destination=store_mock.go -source=./store.go -build_flags=-mod=mod +// // Package store is a generated GoMock package. package store @@ -11,7 +16,6 @@ import ( reflect "reflect" time "time" - 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" @@ -28,12 +32,14 @@ import ( types3 "github.com/netbirdio/netbird/management/server/types" route "github.com/netbirdio/netbird/route" crypt "github.com/netbirdio/netbird/util/crypt" + gomock "go.uber.org/mock/gomock" ) // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller recorder *MockStoreMockRecorder + isgomock struct{} } // MockStoreMockRecorder is the mock recorder for MockStore. @@ -63,7 +69,7 @@ func (m *MockStore) AccountExists(ctx context.Context, lockStrength LockingStren } // AccountExists indicates an expected call of AccountExists. -func (mr *MockStoreMockRecorder) AccountExists(ctx, lockStrength, id interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) AccountExists(ctx, lockStrength, id any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountExists", reflect.TypeOf((*MockStore)(nil).AccountExists), ctx, lockStrength, id) } @@ -77,23 +83,23 @@ func (m *MockStore) AcquireGlobalLock(ctx context.Context) func() { } // AcquireGlobalLock indicates an expected call of AcquireGlobalLock. -func (mr *MockStoreMockRecorder) AcquireGlobalLock(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) AcquireGlobalLock(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcquireGlobalLock", reflect.TypeOf((*MockStore)(nil).AcquireGlobalLock), ctx) } // AddPeerToAccount mocks base method. -func (m *MockStore) AddPeerToAccount(ctx context.Context, peer *peer.Peer) error { +func (m *MockStore) AddPeerToAccount(ctx context.Context, arg1 *peer.Peer) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddPeerToAccount", ctx, peer) + ret := m.ctrl.Call(m, "AddPeerToAccount", ctx, arg1) ret0, _ := ret[0].(error) return ret0 } // AddPeerToAccount indicates an expected call of AddPeerToAccount. -func (mr *MockStoreMockRecorder) AddPeerToAccount(ctx, peer interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) AddPeerToAccount(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToAccount", reflect.TypeOf((*MockStore)(nil).AddPeerToAccount), ctx, peer) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToAccount", reflect.TypeOf((*MockStore)(nil).AddPeerToAccount), ctx, arg1) } // AddPeerToAllGroup mocks base method. @@ -105,7 +111,7 @@ func (m *MockStore) AddPeerToAllGroup(ctx context.Context, accountID, peerID str } // AddPeerToAllGroup indicates an expected call of AddPeerToAllGroup. -func (mr *MockStoreMockRecorder) AddPeerToAllGroup(ctx, accountID, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) AddPeerToAllGroup(ctx, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToAllGroup", reflect.TypeOf((*MockStore)(nil).AddPeerToAllGroup), ctx, accountID, peerID) } @@ -119,7 +125,7 @@ func (m *MockStore) AddPeerToGroup(ctx context.Context, accountID, peerId, group } // AddPeerToGroup indicates an expected call of AddPeerToGroup. -func (mr *MockStoreMockRecorder) AddPeerToGroup(ctx, accountID, peerId, groupID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) AddPeerToGroup(ctx, accountID, peerId, groupID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToGroup", reflect.TypeOf((*MockStore)(nil).AddPeerToGroup), ctx, accountID, peerId, groupID) } @@ -133,7 +139,7 @@ func (m *MockStore) AddResourceToGroup(ctx context.Context, accountId, groupID s } // AddResourceToGroup indicates an expected call of AddResourceToGroup. -func (mr *MockStoreMockRecorder) AddResourceToGroup(ctx, accountId, groupID, resource interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) AddResourceToGroup(ctx, accountId, groupID, resource any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddResourceToGroup", reflect.TypeOf((*MockStore)(nil).AddResourceToGroup), ctx, accountId, groupID, resource) } @@ -148,7 +154,7 @@ func (m *MockStore) ApproveAccountPeers(ctx context.Context, accountID string) ( } // ApproveAccountPeers indicates an expected call of ApproveAccountPeers. -func (mr *MockStoreMockRecorder) ApproveAccountPeers(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) ApproveAccountPeers(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveAccountPeers", reflect.TypeOf((*MockStore)(nil).ApproveAccountPeers), ctx, accountID) } @@ -162,7 +168,7 @@ func (m *MockStore) CleanupStaleProxies(ctx context.Context, inactivityDuration } // CleanupStaleProxies indicates an expected call of CleanupStaleProxies. -func (mr *MockStoreMockRecorder) CleanupStaleProxies(ctx, inactivityDuration interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CleanupStaleProxies(ctx, inactivityDuration any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStaleProxies", reflect.TypeOf((*MockStore)(nil).CleanupStaleProxies), ctx, inactivityDuration) } @@ -176,7 +182,7 @@ func (m *MockStore) Close(ctx context.Context) error { } // Close indicates an expected call of Close. -func (mr *MockStoreMockRecorder) Close(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) Close(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStore)(nil).Close), ctx) } @@ -190,24 +196,24 @@ func (m *MockStore) CompletePeerJob(ctx context.Context, job *types3.Job) error } // CompletePeerJob indicates an expected call of CompletePeerJob. -func (mr *MockStoreMockRecorder) CompletePeerJob(ctx, job interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CompletePeerJob(ctx, job any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CompletePeerJob", reflect.TypeOf((*MockStore)(nil).CompletePeerJob), ctx, job) } // CountAccountsByPrivateDomain mocks base method. -func (m *MockStore) CountAccountsByPrivateDomain(ctx context.Context, domain string) (int64, error) { +func (m *MockStore) CountAccountsByPrivateDomain(ctx context.Context, arg1 string) (int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CountAccountsByPrivateDomain", ctx, domain) + ret := m.ctrl.Call(m, "CountAccountsByPrivateDomain", ctx, arg1) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // CountAccountsByPrivateDomain indicates an expected call of CountAccountsByPrivateDomain. -func (mr *MockStoreMockRecorder) CountAccountsByPrivateDomain(ctx, domain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CountAccountsByPrivateDomain(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountsByPrivateDomain", reflect.TypeOf((*MockStore)(nil).CountAccountsByPrivateDomain), ctx, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountsByPrivateDomain", reflect.TypeOf((*MockStore)(nil).CountAccountsByPrivateDomain), ctx, arg1) } // CountEphemeralServicesByPeer mocks base method. @@ -220,7 +226,7 @@ func (m *MockStore) CountEphemeralServicesByPeer(ctx context.Context, lockStreng } // CountEphemeralServicesByPeer indicates an expected call of CountEphemeralServicesByPeer. -func (mr *MockStoreMockRecorder) CountEphemeralServicesByPeer(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CountEphemeralServicesByPeer(ctx, lockStrength, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountEphemeralServicesByPeer", reflect.TypeOf((*MockStore)(nil).CountEphemeralServicesByPeer), ctx, lockStrength, accountID, peerID) } @@ -235,7 +241,7 @@ func (m *MockStore) CountProxiesByAccountID(ctx context.Context, accountID strin } // CountProxiesByAccountID indicates an expected call of CountProxiesByAccountID. -func (mr *MockStoreMockRecorder) CountProxiesByAccountID(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CountProxiesByAccountID(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountProxiesByAccountID", reflect.TypeOf((*MockStore)(nil).CountProxiesByAccountID), ctx, accountID) } @@ -249,7 +255,7 @@ func (m *MockStore) CreateAccessLog(ctx context.Context, log *accesslogs.AccessL } // CreateAccessLog indicates an expected call of CreateAccessLog. -func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAccessLog), ctx, log) } @@ -263,7 +269,7 @@ func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *type } // CreateAgentNetworkAccessLog indicates an expected call of CreateAgentNetworkAccessLog. -func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups) } @@ -277,7 +283,7 @@ func (m *MockStore) CreateAgentNetworkSettings(ctx context.Context, settings *ty } // CreateAgentNetworkSettings indicates an expected call of CreateAgentNetworkSettings. -func (mr *MockStoreMockRecorder) CreateAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateAgentNetworkSettings(ctx, settings any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkSettings), ctx, settings) } @@ -291,7 +297,7 @@ func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.Ag } // CreateAgentNetworkUsage indicates an expected call of CreateAgentNetworkUsage. -func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkUsage", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkUsage), ctx, usage, groups) } @@ -306,7 +312,7 @@ func (m *MockStore) CreateCustomDomain(ctx context.Context, accountID, domainNam } // CreateCustomDomain indicates an expected call of CreateCustomDomain. -func (mr *MockStoreMockRecorder) CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCustomDomain", reflect.TypeOf((*MockStore)(nil).CreateCustomDomain), ctx, accountID, domainName, targetCluster, validated) } @@ -320,7 +326,7 @@ func (m *MockStore) CreateDNSRecord(ctx context.Context, record *records.Record) } // CreateDNSRecord indicates an expected call of CreateDNSRecord. -func (mr *MockStoreMockRecorder) CreateDNSRecord(ctx, record interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateDNSRecord(ctx, record any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDNSRecord", reflect.TypeOf((*MockStore)(nil).CreateDNSRecord), ctx, record) } @@ -334,7 +340,7 @@ func (m *MockStore) CreateGroup(ctx context.Context, group *types3.Group) error } // CreateGroup indicates an expected call of CreateGroup. -func (mr *MockStoreMockRecorder) CreateGroup(ctx, group interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateGroup(ctx, group any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroup", reflect.TypeOf((*MockStore)(nil).CreateGroup), ctx, group) } @@ -348,7 +354,7 @@ func (m *MockStore) CreateGroups(ctx context.Context, accountID string, groups [ } // CreateGroups indicates an expected call of CreateGroups. -func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockStore)(nil).CreateGroups), ctx, accountID, groups) } @@ -362,7 +368,7 @@ func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types1.Netw } // CreateNetworkRouter indicates an expected call of CreateNetworkRouter. -func (mr *MockStoreMockRecorder) CreateNetworkRouter(ctx, router interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateNetworkRouter(ctx, router any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNetworkRouter", reflect.TypeOf((*MockStore)(nil).CreateNetworkRouter), ctx, router) } @@ -376,7 +382,7 @@ func (m *MockStore) CreatePeerJob(ctx context.Context, job *types3.Job) error { } // CreatePeerJob indicates an expected call of CreatePeerJob. -func (mr *MockStoreMockRecorder) CreatePeerJob(ctx, job interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreatePeerJob(ctx, job any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePeerJob", reflect.TypeOf((*MockStore)(nil).CreatePeerJob), ctx, job) } @@ -390,23 +396,23 @@ func (m *MockStore) CreatePolicy(ctx context.Context, policy *types3.Policy) err } // CreatePolicy indicates an expected call of CreatePolicy. -func (mr *MockStoreMockRecorder) CreatePolicy(ctx, policy interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreatePolicy(ctx, policy any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePolicy", reflect.TypeOf((*MockStore)(nil).CreatePolicy), ctx, policy) } // CreateService mocks base method. -func (m *MockStore) CreateService(ctx context.Context, service *service.Service) error { +func (m *MockStore) CreateService(ctx context.Context, arg1 *service.Service) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateService", ctx, service) + ret := m.ctrl.Call(m, "CreateService", ctx, arg1) ret0, _ := ret[0].(error) return ret0 } // CreateService indicates an expected call of CreateService. -func (mr *MockStoreMockRecorder) CreateService(ctx, service interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateService(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockStore)(nil).CreateService), ctx, service) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockStore)(nil).CreateService), ctx, arg1) } // CreateZone mocks base method. @@ -418,7 +424,7 @@ func (m *MockStore) CreateZone(ctx context.Context, zone *zones.Zone) error { } // CreateZone indicates an expected call of CreateZone. -func (mr *MockStoreMockRecorder) CreateZone(ctx, zone interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateZone(ctx, zone any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateZone", reflect.TypeOf((*MockStore)(nil).CreateZone), ctx, zone) } @@ -432,7 +438,7 @@ func (m *MockStore) DeleteAccount(ctx context.Context, account *types3.Account) } // DeleteAccount indicates an expected call of DeleteAccount. -func (mr *MockStoreMockRecorder) DeleteAccount(ctx, account interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteAccount(ctx, account any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccount", reflect.TypeOf((*MockStore)(nil).DeleteAccount), ctx, account) } @@ -446,7 +452,7 @@ func (m *MockStore) DeleteAccountCluster(ctx context.Context, clusterAddress, ac } // DeleteAccountCluster indicates an expected call of DeleteAccountCluster. -func (mr *MockStoreMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockStore)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) } @@ -460,7 +466,7 @@ func (m *MockStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, } // DeleteAgentNetworkBudgetRule indicates an expected call of DeleteAgentNetworkBudgetRule. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) } @@ -474,7 +480,7 @@ func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, } // DeleteAgentNetworkGuardrail indicates an expected call of DeleteAgentNetworkGuardrail. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkGuardrail), ctx, accountID, guardrailID) } @@ -488,7 +494,7 @@ func (m *MockStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, pol } // DeleteAgentNetworkPolicy indicates an expected call of DeleteAgentNetworkPolicy. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkPolicy), ctx, accountID, policyID) } @@ -502,7 +508,7 @@ func (m *MockStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, p } // DeleteAgentNetworkProvider indicates an expected call of DeleteAgentNetworkProvider. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID) } @@ -516,7 +522,7 @@ func (m *MockStore) DeleteAgentNetworkSettings(ctx context.Context, accountID st } // DeleteAgentNetworkSettings indicates an expected call of DeleteAgentNetworkSettings. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkSettings(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteAgentNetworkSettings(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkSettings), ctx, accountID) } @@ -530,7 +536,7 @@ func (m *MockStore) DeleteCustomDomain(ctx context.Context, accountID, domainID } // DeleteCustomDomain indicates an expected call of DeleteCustomDomain. -func (mr *MockStoreMockRecorder) DeleteCustomDomain(ctx, accountID, domainID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteCustomDomain(ctx, accountID, domainID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCustomDomain", reflect.TypeOf((*MockStore)(nil).DeleteCustomDomain), ctx, accountID, domainID) } @@ -544,7 +550,7 @@ func (m *MockStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, reco } // DeleteDNSRecord indicates an expected call of DeleteDNSRecord. -func (mr *MockStoreMockRecorder) DeleteDNSRecord(ctx, accountID, zoneID, recordID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteDNSRecord(ctx, accountID, zoneID, recordID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDNSRecord", reflect.TypeOf((*MockStore)(nil).DeleteDNSRecord), ctx, accountID, zoneID, recordID) } @@ -558,7 +564,7 @@ func (m *MockStore) DeleteGroup(ctx context.Context, accountID, groupID string) } // DeleteGroup indicates an expected call of DeleteGroup. -func (mr *MockStoreMockRecorder) DeleteGroup(ctx, accountID, groupID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteGroup(ctx, accountID, groupID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockStore)(nil).DeleteGroup), ctx, accountID, groupID) } @@ -572,7 +578,7 @@ func (m *MockStore) DeleteGroups(ctx context.Context, accountID string, groupIDs } // DeleteGroups indicates an expected call of DeleteGroups. -func (mr *MockStoreMockRecorder) DeleteGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteGroups(ctx, accountID, groupIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroups", reflect.TypeOf((*MockStore)(nil).DeleteGroups), ctx, accountID, groupIDs) } @@ -586,7 +592,7 @@ func (m *MockStore) DeleteHashedPAT2TokenIDIndex(hashedToken string) error { } // DeleteHashedPAT2TokenIDIndex indicates an expected call of DeleteHashedPAT2TokenIDIndex. -func (mr *MockStoreMockRecorder) DeleteHashedPAT2TokenIDIndex(hashedToken interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteHashedPAT2TokenIDIndex(hashedToken any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHashedPAT2TokenIDIndex", reflect.TypeOf((*MockStore)(nil).DeleteHashedPAT2TokenIDIndex), hashedToken) } @@ -600,7 +606,7 @@ func (m *MockStore) DeleteNameServerGroup(ctx context.Context, accountID, nameSe } // DeleteNameServerGroup indicates an expected call of DeleteNameServerGroup. -func (mr *MockStoreMockRecorder) DeleteNameServerGroup(ctx, accountID, nameServerGroupID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteNameServerGroup(ctx, accountID, nameServerGroupID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNameServerGroup", reflect.TypeOf((*MockStore)(nil).DeleteNameServerGroup), ctx, accountID, nameServerGroupID) } @@ -614,7 +620,7 @@ func (m *MockStore) DeleteNetwork(ctx context.Context, accountID, networkID stri } // DeleteNetwork indicates an expected call of DeleteNetwork. -func (mr *MockStoreMockRecorder) DeleteNetwork(ctx, accountID, networkID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteNetwork(ctx, accountID, networkID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetwork", reflect.TypeOf((*MockStore)(nil).DeleteNetwork), ctx, accountID, networkID) } @@ -628,7 +634,7 @@ func (m *MockStore) DeleteNetworkResource(ctx context.Context, accountID, resour } // DeleteNetworkResource indicates an expected call of DeleteNetworkResource. -func (mr *MockStoreMockRecorder) DeleteNetworkResource(ctx, accountID, resourceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteNetworkResource(ctx, accountID, resourceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkResource", reflect.TypeOf((*MockStore)(nil).DeleteNetworkResource), ctx, accountID, resourceID) } @@ -642,7 +648,7 @@ func (m *MockStore) DeleteNetworkRouter(ctx context.Context, accountID, routerID } // DeleteNetworkRouter indicates an expected call of DeleteNetworkRouter. -func (mr *MockStoreMockRecorder) DeleteNetworkRouter(ctx, accountID, routerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteNetworkRouter(ctx, accountID, routerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkRouter", reflect.TypeOf((*MockStore)(nil).DeleteNetworkRouter), ctx, accountID, routerID) } @@ -657,7 +663,7 @@ func (m *MockStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time } // DeleteOldAccessLogs indicates an expected call of DeleteOldAccessLogs. -func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAccessLogs), ctx, olderThan) } @@ -672,7 +678,7 @@ func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, account } // DeleteOldAgentNetworkAccessLogs indicates an expected call of DeleteOldAgentNetworkAccessLogs. -func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAgentNetworkAccessLogs), ctx, accountID, olderThan) } @@ -686,7 +692,7 @@ func (m *MockStore) DeletePAT(ctx context.Context, userID, patID string) error { } // DeletePAT indicates an expected call of DeletePAT. -func (mr *MockStoreMockRecorder) DeletePAT(ctx, userID, patID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeletePAT(ctx, userID, patID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePAT", reflect.TypeOf((*MockStore)(nil).DeletePAT), ctx, userID, patID) } @@ -700,7 +706,7 @@ func (m *MockStore) DeletePeer(ctx context.Context, accountID, peerID string) er } // DeletePeer indicates an expected call of DeletePeer. -func (mr *MockStoreMockRecorder) DeletePeer(ctx, accountID, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeletePeer(ctx, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeer", reflect.TypeOf((*MockStore)(nil).DeletePeer), ctx, accountID, peerID) } @@ -714,7 +720,7 @@ func (m *MockStore) DeletePolicy(ctx context.Context, accountID, policyID string } // DeletePolicy indicates an expected call of DeletePolicy. -func (mr *MockStoreMockRecorder) DeletePolicy(ctx, accountID, policyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeletePolicy(ctx, accountID, policyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePolicy", reflect.TypeOf((*MockStore)(nil).DeletePolicy), ctx, accountID, policyID) } @@ -728,7 +734,7 @@ func (m *MockStore) DeletePostureChecks(ctx context.Context, accountID, postureC } // DeletePostureChecks indicates an expected call of DeletePostureChecks. -func (mr *MockStoreMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockStore)(nil).DeletePostureChecks), ctx, accountID, postureChecksID) } @@ -742,7 +748,7 @@ func (m *MockStore) DeleteRoute(ctx context.Context, accountID, routeID string) } // DeleteRoute indicates an expected call of DeleteRoute. -func (mr *MockStoreMockRecorder) DeleteRoute(ctx, accountID, routeID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteRoute(ctx, accountID, routeID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRoute", reflect.TypeOf((*MockStore)(nil).DeleteRoute), ctx, accountID, routeID) } @@ -756,7 +762,7 @@ func (m *MockStore) DeleteService(ctx context.Context, accountID, serviceID stri } // DeleteService indicates an expected call of DeleteService. -func (mr *MockStoreMockRecorder) DeleteService(ctx, accountID, serviceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteService(ctx, accountID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockStore)(nil).DeleteService), ctx, accountID, serviceID) } @@ -770,7 +776,7 @@ func (m *MockStore) DeleteServiceTargets(ctx context.Context, accountID, service } // DeleteServiceTargets indicates an expected call of DeleteServiceTargets. -func (mr *MockStoreMockRecorder) DeleteServiceTargets(ctx, accountID, serviceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteServiceTargets(ctx, accountID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteServiceTargets", reflect.TypeOf((*MockStore)(nil).DeleteServiceTargets), ctx, accountID, serviceID) } @@ -784,7 +790,7 @@ func (m *MockStore) DeleteSetupKey(ctx context.Context, accountID, keyID string) } // DeleteSetupKey indicates an expected call of DeleteSetupKey. -func (mr *MockStoreMockRecorder) DeleteSetupKey(ctx, accountID, keyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteSetupKey(ctx, accountID, keyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSetupKey", reflect.TypeOf((*MockStore)(nil).DeleteSetupKey), ctx, accountID, keyID) } @@ -798,7 +804,7 @@ func (m *MockStore) DeleteTarget(ctx context.Context, accountID, serviceID strin } // DeleteTarget indicates an expected call of DeleteTarget. -func (mr *MockStoreMockRecorder) DeleteTarget(ctx, accountID, serviceID, targetID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteTarget(ctx, accountID, serviceID, targetID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTarget", reflect.TypeOf((*MockStore)(nil).DeleteTarget), ctx, accountID, serviceID, targetID) } @@ -812,7 +818,7 @@ func (m *MockStore) DeleteTokenID2UserIDIndex(tokenID string) error { } // DeleteTokenID2UserIDIndex indicates an expected call of DeleteTokenID2UserIDIndex. -func (mr *MockStoreMockRecorder) DeleteTokenID2UserIDIndex(tokenID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteTokenID2UserIDIndex(tokenID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTokenID2UserIDIndex", reflect.TypeOf((*MockStore)(nil).DeleteTokenID2UserIDIndex), tokenID) } @@ -826,7 +832,7 @@ func (m *MockStore) DeleteUser(ctx context.Context, accountID, userID string) er } // DeleteUser indicates an expected call of DeleteUser. -func (mr *MockStoreMockRecorder) DeleteUser(ctx, accountID, userID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteUser(ctx, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockStore)(nil).DeleteUser), ctx, accountID, userID) } @@ -840,7 +846,7 @@ func (m *MockStore) DeleteUserInvite(ctx context.Context, inviteID string) error } // DeleteUserInvite indicates an expected call of DeleteUserInvite. -func (mr *MockStoreMockRecorder) DeleteUserInvite(ctx, inviteID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteUserInvite(ctx, inviteID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserInvite", reflect.TypeOf((*MockStore)(nil).DeleteUserInvite), ctx, inviteID) } @@ -854,7 +860,7 @@ func (m *MockStore) DeleteZone(ctx context.Context, accountID, zoneID string) er } // DeleteZone indicates an expected call of DeleteZone. -func (mr *MockStoreMockRecorder) DeleteZone(ctx, accountID, zoneID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteZone(ctx, accountID, zoneID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZone", reflect.TypeOf((*MockStore)(nil).DeleteZone), ctx, accountID, zoneID) } @@ -868,7 +874,7 @@ func (m *MockStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID } // DeleteZoneDNSRecords indicates an expected call of DeleteZoneDNSRecords. -func (mr *MockStoreMockRecorder) DeleteZoneDNSRecords(ctx, accountID, zoneID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DeleteZoneDNSRecords(ctx, accountID, zoneID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).DeleteZoneDNSRecords), ctx, accountID, zoneID) } @@ -883,7 +889,7 @@ func (m *MockStore) DisconnectAllProxies(ctx context.Context) (int64, error) { } // DisconnectAllProxies indicates an expected call of DisconnectAllProxies. -func (mr *MockStoreMockRecorder) DisconnectAllProxies(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DisconnectAllProxies(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectAllProxies", reflect.TypeOf((*MockStore)(nil).DisconnectAllProxies), ctx) } @@ -897,24 +903,24 @@ func (m *MockStore) DisconnectProxy(ctx context.Context, proxyID, sessionID stri } // DisconnectProxy indicates an expected call of DisconnectProxy. -func (mr *MockStoreMockRecorder) DisconnectProxy(ctx, proxyID, sessionID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) DisconnectProxy(ctx, proxyID, sessionID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectProxy", reflect.TypeOf((*MockStore)(nil).DisconnectProxy), ctx, proxyID, sessionID) } // EphemeralServiceExists mocks base method. -func (m *MockStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) { +func (m *MockStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, arg4 string) (bool, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EphemeralServiceExists", ctx, lockStrength, accountID, peerID, domain) + ret := m.ctrl.Call(m, "EphemeralServiceExists", ctx, lockStrength, accountID, peerID, arg4) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(error) return ret0, ret1 } // EphemeralServiceExists indicates an expected call of EphemeralServiceExists. -func (mr *MockStoreMockRecorder) EphemeralServiceExists(ctx, lockStrength, accountID, peerID, domain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) EphemeralServiceExists(ctx, lockStrength, accountID, peerID, arg4 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EphemeralServiceExists", reflect.TypeOf((*MockStore)(nil).EphemeralServiceExists), ctx, lockStrength, accountID, peerID, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EphemeralServiceExists", reflect.TypeOf((*MockStore)(nil).EphemeralServiceExists), ctx, lockStrength, accountID, peerID, arg4) } // ExecuteInTransaction mocks base method. @@ -926,7 +932,7 @@ func (m *MockStore) ExecuteInTransaction(ctx context.Context, f func(Store) erro } // ExecuteInTransaction indicates an expected call of ExecuteInTransaction. -func (mr *MockStoreMockRecorder) ExecuteInTransaction(ctx, f interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) ExecuteInTransaction(ctx, f any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteInTransaction", reflect.TypeOf((*MockStore)(nil).ExecuteInTransaction), ctx, f) } @@ -941,7 +947,7 @@ func (m *MockStore) GetAccount(ctx context.Context, accountID string) (*types3.A } // GetAccount indicates an expected call of GetAccount. -func (mr *MockStoreMockRecorder) GetAccount(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccount(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockStore)(nil).GetAccount), ctx, accountID) } @@ -957,7 +963,7 @@ func (m *MockStore) GetAccountAccessLogs(ctx context.Context, lockStrength Locki } // GetAccountAccessLogs indicates an expected call of GetAccountAccessLogs. -func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, accountID, filter any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAccountAccessLogs), ctx, lockStrength, accountID, filter) } @@ -972,7 +978,7 @@ func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockS } // GetAccountAgentNetworkBudgetRules indicates an expected call of GetAccountAgentNetworkBudgetRules. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkBudgetRules", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkBudgetRules), ctx, lockStrength, accountID) } @@ -987,7 +993,7 @@ func (m *MockStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockSt } // GetAccountAgentNetworkGuardrails indicates an expected call of GetAccountAgentNetworkGuardrails. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkGuardrails", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkGuardrails), ctx, lockStrength, accountID) } @@ -1002,7 +1008,7 @@ func (m *MockStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStre } // GetAccountAgentNetworkPolicies indicates an expected call of GetAccountAgentNetworkPolicies. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkPolicies), ctx, lockStrength, accountID) } @@ -1017,7 +1023,7 @@ func (m *MockStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStr } // GetAccountAgentNetworkProviders indicates an expected call of GetAccountAgentNetworkProviders. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkProviders), ctx, lockStrength, accountID) } @@ -1032,7 +1038,7 @@ func (m *MockStore) GetAccountByPeerID(ctx context.Context, peerID string) (*typ } // GetAccountByPeerID indicates an expected call of GetAccountByPeerID. -func (mr *MockStoreMockRecorder) GetAccountByPeerID(ctx, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountByPeerID(ctx, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerID", reflect.TypeOf((*MockStore)(nil).GetAccountByPeerID), ctx, peerID) } @@ -1047,24 +1053,24 @@ func (m *MockStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) } // GetAccountByPeerPubKey indicates an expected call of GetAccountByPeerPubKey. -func (mr *MockStoreMockRecorder) GetAccountByPeerPubKey(ctx, peerKey interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountByPeerPubKey(ctx, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetAccountByPeerPubKey), ctx, peerKey) } // GetAccountByPrivateDomain mocks base method. -func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types3.Account, error) { +func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, arg1 string) (*types3.Account, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountByPrivateDomain", ctx, domain) + ret := m.ctrl.Call(m, "GetAccountByPrivateDomain", ctx, arg1) ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccountByPrivateDomain indicates an expected call of GetAccountByPrivateDomain. -func (mr *MockStoreMockRecorder) GetAccountByPrivateDomain(ctx, domain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountByPrivateDomain(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountByPrivateDomain), ctx, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountByPrivateDomain), ctx, arg1) } // GetAccountBySetupKey mocks base method. @@ -1077,7 +1083,7 @@ func (m *MockStore) GetAccountBySetupKey(ctx context.Context, setupKey string) ( } // GetAccountBySetupKey indicates an expected call of GetAccountBySetupKey. -func (mr *MockStoreMockRecorder) GetAccountBySetupKey(ctx, setupKey interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountBySetupKey(ctx, setupKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountBySetupKey", reflect.TypeOf((*MockStore)(nil).GetAccountBySetupKey), ctx, setupKey) } @@ -1092,7 +1098,7 @@ func (m *MockStore) GetAccountByUser(ctx context.Context, userID string) (*types } // GetAccountByUser indicates an expected call of GetAccountByUser. -func (mr *MockStoreMockRecorder) GetAccountByUser(ctx, userID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountByUser(ctx, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByUser", reflect.TypeOf((*MockStore)(nil).GetAccountByUser), ctx, userID) } @@ -1107,7 +1113,7 @@ func (m *MockStore) GetAccountCreatedBy(ctx context.Context, lockStrength Lockin } // GetAccountCreatedBy indicates an expected call of GetAccountCreatedBy. -func (mr *MockStoreMockRecorder) GetAccountCreatedBy(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountCreatedBy(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountCreatedBy", reflect.TypeOf((*MockStore)(nil).GetAccountCreatedBy), ctx, lockStrength, accountID) } @@ -1122,7 +1128,7 @@ func (m *MockStore) GetAccountDNSSettings(ctx context.Context, lockStrength Lock } // GetAccountDNSSettings indicates an expected call of GetAccountDNSSettings. -func (mr *MockStoreMockRecorder) GetAccountDNSSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountDNSSettings(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountDNSSettings", reflect.TypeOf((*MockStore)(nil).GetAccountDNSSettings), ctx, lockStrength, accountID) } @@ -1138,7 +1144,7 @@ func (m *MockStore) GetAccountDomainAndCategory(ctx context.Context, lockStrengt } // GetAccountDomainAndCategory indicates an expected call of GetAccountDomainAndCategory. -func (mr *MockStoreMockRecorder) GetAccountDomainAndCategory(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountDomainAndCategory(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountDomainAndCategory", reflect.TypeOf((*MockStore)(nil).GetAccountDomainAndCategory), ctx, lockStrength, accountID) } @@ -1153,7 +1159,7 @@ func (m *MockStore) GetAccountGroupPeers(ctx context.Context, lockStrength Locki } // GetAccountGroupPeers indicates an expected call of GetAccountGroupPeers. -func (mr *MockStoreMockRecorder) GetAccountGroupPeers(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountGroupPeers(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountGroupPeers", reflect.TypeOf((*MockStore)(nil).GetAccountGroupPeers), ctx, lockStrength, accountID) } @@ -1168,7 +1174,7 @@ func (m *MockStore) GetAccountGroups(ctx context.Context, lockStrength LockingSt } // GetAccountGroups indicates an expected call of GetAccountGroups. -func (mr *MockStoreMockRecorder) GetAccountGroups(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountGroups(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountGroups", reflect.TypeOf((*MockStore)(nil).GetAccountGroups), ctx, lockStrength, accountID) } @@ -1183,7 +1189,7 @@ func (m *MockStore) GetAccountIDByPeerID(ctx context.Context, lockStrength Locki } // GetAccountIDByPeerID indicates an expected call of GetAccountIDByPeerID. -func (mr *MockStoreMockRecorder) GetAccountIDByPeerID(ctx, lockStrength, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountIDByPeerID(ctx, lockStrength, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPeerID", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPeerID), ctx, lockStrength, peerID) } @@ -1198,24 +1204,24 @@ func (m *MockStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string } // GetAccountIDByPeerPubKey indicates an expected call of GetAccountIDByPeerPubKey. -func (mr *MockStoreMockRecorder) GetAccountIDByPeerPubKey(ctx, peerKey interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountIDByPeerPubKey(ctx, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPeerPubKey), ctx, peerKey) } // GetAccountIDByPrivateDomain mocks base method. -func (m *MockStore) GetAccountIDByPrivateDomain(ctx context.Context, lockStrength LockingStrength, domain string) (string, error) { +func (m *MockStore) GetAccountIDByPrivateDomain(ctx context.Context, lockStrength LockingStrength, arg2 string) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountIDByPrivateDomain", ctx, lockStrength, domain) + ret := m.ctrl.Call(m, "GetAccountIDByPrivateDomain", ctx, lockStrength, arg2) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccountIDByPrivateDomain indicates an expected call of GetAccountIDByPrivateDomain. -func (mr *MockStoreMockRecorder) GetAccountIDByPrivateDomain(ctx, lockStrength, domain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountIDByPrivateDomain(ctx, lockStrength, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPrivateDomain), ctx, lockStrength, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPrivateDomain), ctx, lockStrength, arg2) } // GetAccountIDBySetupKey mocks base method. @@ -1228,7 +1234,7 @@ func (m *MockStore) GetAccountIDBySetupKey(ctx context.Context, peerKey string) } // GetAccountIDBySetupKey indicates an expected call of GetAccountIDBySetupKey. -func (mr *MockStoreMockRecorder) GetAccountIDBySetupKey(ctx, peerKey interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountIDBySetupKey(ctx, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDBySetupKey", reflect.TypeOf((*MockStore)(nil).GetAccountIDBySetupKey), ctx, peerKey) } @@ -1243,7 +1249,7 @@ func (m *MockStore) GetAccountIDByUserID(ctx context.Context, lockStrength Locki } // GetAccountIDByUserID indicates an expected call of GetAccountIDByUserID. -func (mr *MockStoreMockRecorder) GetAccountIDByUserID(ctx, lockStrength, userID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountIDByUserID(ctx, lockStrength, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByUserID", reflect.TypeOf((*MockStore)(nil).GetAccountIDByUserID), ctx, lockStrength, userID) } @@ -1258,7 +1264,7 @@ func (m *MockStore) GetAccountMeta(ctx context.Context, lockStrength LockingStre } // GetAccountMeta indicates an expected call of GetAccountMeta. -func (mr *MockStoreMockRecorder) GetAccountMeta(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountMeta(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountMeta", reflect.TypeOf((*MockStore)(nil).GetAccountMeta), ctx, lockStrength, accountID) } @@ -1273,7 +1279,7 @@ func (m *MockStore) GetAccountNameServerGroups(ctx context.Context, lockStrength } // GetAccountNameServerGroups indicates an expected call of GetAccountNameServerGroups. -func (mr *MockStoreMockRecorder) GetAccountNameServerGroups(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountNameServerGroups(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNameServerGroups", reflect.TypeOf((*MockStore)(nil).GetAccountNameServerGroups), ctx, lockStrength, accountID) } @@ -1288,7 +1294,7 @@ func (m *MockStore) GetAccountNetwork(ctx context.Context, lockStrength LockingS } // GetAccountNetwork indicates an expected call of GetAccountNetwork. -func (mr *MockStoreMockRecorder) GetAccountNetwork(ctx, lockStrength, accountId interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountNetwork(ctx, lockStrength, accountId any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetwork", reflect.TypeOf((*MockStore)(nil).GetAccountNetwork), ctx, lockStrength, accountId) } @@ -1303,7 +1309,7 @@ func (m *MockStore) GetAccountNetworks(ctx context.Context, lockStrength Locking } // GetAccountNetworks indicates an expected call of GetAccountNetworks. -func (mr *MockStoreMockRecorder) GetAccountNetworks(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountNetworks(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetworks", reflect.TypeOf((*MockStore)(nil).GetAccountNetworks), ctx, lockStrength, accountID) } @@ -1318,7 +1324,7 @@ func (m *MockStore) GetAccountOnboarding(ctx context.Context, accountID string) } // GetAccountOnboarding indicates an expected call of GetAccountOnboarding. -func (mr *MockStoreMockRecorder) GetAccountOnboarding(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountOnboarding(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOnboarding", reflect.TypeOf((*MockStore)(nil).GetAccountOnboarding), ctx, accountID) } @@ -1333,7 +1339,7 @@ func (m *MockStore) GetAccountOwner(ctx context.Context, lockStrength LockingStr } // GetAccountOwner indicates an expected call of GetAccountOwner. -func (mr *MockStoreMockRecorder) GetAccountOwner(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountOwner(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOwner", reflect.TypeOf((*MockStore)(nil).GetAccountOwner), ctx, lockStrength, accountID) } @@ -1348,7 +1354,7 @@ func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStr } // GetAccountPeers indicates an expected call of GetAccountPeers. -func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter) } @@ -1363,7 +1369,7 @@ func (m *MockStore) GetAccountPeersWithExpiration(ctx context.Context, lockStren } // GetAccountPeersWithExpiration indicates an expected call of GetAccountPeersWithExpiration. -func (mr *MockStoreMockRecorder) GetAccountPeersWithExpiration(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPeersWithExpiration(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersWithExpiration", reflect.TypeOf((*MockStore)(nil).GetAccountPeersWithExpiration), ctx, lockStrength, accountID) } @@ -1378,7 +1384,7 @@ func (m *MockStore) GetAccountPeersWithInactivity(ctx context.Context, lockStren } // GetAccountPeersWithInactivity indicates an expected call of GetAccountPeersWithInactivity. -func (mr *MockStoreMockRecorder) GetAccountPeersWithInactivity(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPeersWithInactivity(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersWithInactivity", reflect.TypeOf((*MockStore)(nil).GetAccountPeersWithInactivity), ctx, lockStrength, accountID) } @@ -1393,7 +1399,7 @@ func (m *MockStore) GetAccountPolicies(ctx context.Context, lockStrength Locking } // GetAccountPolicies indicates an expected call of GetAccountPolicies. -func (mr *MockStoreMockRecorder) GetAccountPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPolicies(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountPolicies), ctx, lockStrength, accountID) } @@ -1408,7 +1414,7 @@ func (m *MockStore) GetAccountPostureChecks(ctx context.Context, lockStrength Lo } // GetAccountPostureChecks indicates an expected call of GetAccountPostureChecks. -func (mr *MockStoreMockRecorder) GetAccountPostureChecks(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPostureChecks(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPostureChecks", reflect.TypeOf((*MockStore)(nil).GetAccountPostureChecks), ctx, lockStrength, accountID) } @@ -1423,7 +1429,7 @@ func (m *MockStore) GetAccountRoutes(ctx context.Context, lockStrength LockingSt } // GetAccountRoutes indicates an expected call of GetAccountRoutes. -func (mr *MockStoreMockRecorder) GetAccountRoutes(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountRoutes(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountRoutes", reflect.TypeOf((*MockStore)(nil).GetAccountRoutes), ctx, lockStrength, accountID) } @@ -1438,7 +1444,7 @@ func (m *MockStore) GetAccountServices(ctx context.Context, lockStrength Locking } // GetAccountServices indicates an expected call of GetAccountServices. -func (mr *MockStoreMockRecorder) GetAccountServices(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountServices(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockStore)(nil).GetAccountServices), ctx, lockStrength, accountID) } @@ -1453,7 +1459,7 @@ func (m *MockStore) GetAccountSettings(ctx context.Context, lockStrength Locking } // GetAccountSettings indicates an expected call of GetAccountSettings. -func (mr *MockStoreMockRecorder) GetAccountSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountSettings(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSettings", reflect.TypeOf((*MockStore)(nil).GetAccountSettings), ctx, lockStrength, accountID) } @@ -1468,7 +1474,7 @@ func (m *MockStore) GetAccountSetupKeys(ctx context.Context, lockStrength Lockin } // GetAccountSetupKeys indicates an expected call of GetAccountSetupKeys. -func (mr *MockStoreMockRecorder) GetAccountSetupKeys(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountSetupKeys(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSetupKeys", reflect.TypeOf((*MockStore)(nil).GetAccountSetupKeys), ctx, lockStrength, accountID) } @@ -1483,7 +1489,7 @@ func (m *MockStore) GetAccountUserInvites(ctx context.Context, lockStrength Lock } // GetAccountUserInvites indicates an expected call of GetAccountUserInvites. -func (mr *MockStoreMockRecorder) GetAccountUserInvites(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountUserInvites(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountUserInvites", reflect.TypeOf((*MockStore)(nil).GetAccountUserInvites), ctx, lockStrength, accountID) } @@ -1498,7 +1504,7 @@ func (m *MockStore) GetAccountUsers(ctx context.Context, lockStrength LockingStr } // GetAccountUsers indicates an expected call of GetAccountUsers. -func (mr *MockStoreMockRecorder) GetAccountUsers(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountUsers(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountUsers", reflect.TypeOf((*MockStore)(nil).GetAccountUsers), ctx, lockStrength, accountID) } @@ -1513,7 +1519,7 @@ func (m *MockStore) GetAccountZones(ctx context.Context, lockStrength LockingStr } // GetAccountZones indicates an expected call of GetAccountZones. -func (mr *MockStoreMockRecorder) GetAccountZones(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountZones(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountZones", reflect.TypeOf((*MockStore)(nil).GetAccountZones), ctx, lockStrength, accountID) } @@ -1528,7 +1534,7 @@ func (m *MockStore) GetAccountsCounter(ctx context.Context) (int64, error) { } // GetAccountsCounter indicates an expected call of GetAccountsCounter. -func (mr *MockStoreMockRecorder) GetAccountsCounter(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountsCounter(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountsCounter", reflect.TypeOf((*MockStore)(nil).GetAccountsCounter), ctx) } @@ -1543,7 +1549,7 @@ func (m *MockStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]strin } // GetActiveProxyClusterAddresses indicates an expected call of GetActiveProxyClusterAddresses. -func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddresses(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddresses(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddresses", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddresses), ctx) } @@ -1558,7 +1564,7 @@ func (m *MockStore) GetActiveProxyClusterAddressesForAccount(ctx context.Context } // GetActiveProxyClusterAddressesForAccount indicates an expected call of GetActiveProxyClusterAddressesForAccount. -func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddressesForAccount", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddressesForAccount), ctx, accountID) } @@ -1574,7 +1580,7 @@ func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockSt } // GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. -func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) } @@ -1590,7 +1596,7 @@ func (m *MockStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength } // GetAgentNetworkAccessLogs indicates an expected call of GetAgentNetworkAccessLogs. -func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) } @@ -1605,7 +1611,7 @@ func (m *MockStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStren } // GetAgentNetworkBudgetRuleByID indicates an expected call of GetAgentNetworkBudgetRuleByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkBudgetRuleByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkBudgetRuleByID), ctx, lockStrength, accountID, ruleID) } @@ -1620,7 +1626,7 @@ func (m *MockStore) GetAgentNetworkConsumption(ctx context.Context, lockStrength } // GetAgentNetworkConsumption indicates an expected call of GetAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart any) *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) } @@ -1635,7 +1641,7 @@ func (m *MockStore) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStr } // GetAgentNetworkConsumptionBatch indicates an expected call of GetAgentNetworkConsumptionBatch. -func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumptionBatch), ctx, lockStrength, accountID, keys) } @@ -1650,7 +1656,7 @@ func (m *MockStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStreng } // GetAgentNetworkGuardrailByID indicates an expected call of GetAgentNetworkGuardrailByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkGuardrailByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkGuardrailByID), ctx, lockStrength, accountID, guardrailID) } @@ -1665,7 +1671,7 @@ func (m *MockStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMet } // GetAgentNetworkMetrics indicates an expected call of GetAgentNetworkMetrics. -func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkMetrics", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkMetrics), ctx) } @@ -1680,7 +1686,7 @@ func (m *MockStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength } // GetAgentNetworkPolicyByID indicates an expected call of GetAgentNetworkPolicyByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkPolicyByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkPolicyByID), ctx, lockStrength, accountID, policyID) } @@ -1695,7 +1701,7 @@ func (m *MockStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrengt } // GetAgentNetworkProviderByID indicates an expected call of GetAgentNetworkProviderByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkProviderByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkProviderByID), ctx, lockStrength, accountID, providerID) } @@ -1710,24 +1716,24 @@ func (m *MockStore) GetAgentNetworkSettings(ctx context.Context, lockStrength Lo } // GetAgentNetworkSettings indicates an expected call of GetAgentNetworkSettings. -func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID) } // GetAgentNetworkSettingsByDomain mocks base method. -func (m *MockStore) GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, domain string) (*types.Settings, error) { +func (m *MockStore) GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, arg2 string) (*types.Settings, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByDomain", ctx, lockStrength, domain) + ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByDomain", ctx, lockStrength, arg2) ret0, _ := ret[0].(*types.Settings) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAgentNetworkSettingsByDomain indicates an expected call of GetAgentNetworkSettingsByDomain. -func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByDomain(ctx, lockStrength, domain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByDomain(ctx, lockStrength, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByDomain", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByDomain), ctx, lockStrength, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByDomain", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByDomain), ctx, lockStrength, arg2) } // GetAgentNetworkSettingsByProxyAddress mocks base method. @@ -1740,7 +1746,7 @@ func (m *MockStore) GetAgentNetworkSettingsByProxyAddress(ctx context.Context, l } // GetAgentNetworkSettingsByProxyAddress indicates an expected call of GetAgentNetworkSettingsByProxyAddress. -func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByProxyAddress(ctx, lockStrength, proxyAddress interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByProxyAddress(ctx, lockStrength, proxyAddress any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByProxyAddress", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByProxyAddress), ctx, lockStrength, proxyAddress) } @@ -1755,7 +1761,7 @@ func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength L } // GetAgentNetworkUsageRows indicates an expected call of GetAgentNetworkUsageRows. -func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkUsageRows", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkUsageRows), ctx, lockStrength, accountID, filter) } @@ -1769,7 +1775,7 @@ func (m *MockStore) GetAllAccounts(ctx context.Context) []*types3.Account { } // GetAllAccounts indicates an expected call of GetAllAccounts. -func (mr *MockStoreMockRecorder) GetAllAccounts(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAllAccounts(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAccounts", reflect.TypeOf((*MockStore)(nil).GetAllAccounts), ctx) } @@ -1784,7 +1790,7 @@ func (m *MockStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrengt } // GetAllAgentNetworkProviders indicates an expected call of GetAllAgentNetworkProviders. -func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkProviders), ctx, lockStrength) } @@ -1799,7 +1805,7 @@ func (m *MockStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength } // GetAllAgentNetworkSettings indicates an expected call of GetAllAgentNetworkSettings. -func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkSettings), ctx, lockStrength) } @@ -1814,7 +1820,7 @@ func (m *MockStore) GetAllEphemeralPeers(ctx context.Context, lockStrength Locki } // GetAllEphemeralPeers indicates an expected call of GetAllEphemeralPeers. -func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllEphemeralPeers", reflect.TypeOf((*MockStore)(nil).GetAllEphemeralPeers), ctx, lockStrength) } @@ -1829,7 +1835,7 @@ func (m *MockStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { } // GetAllProxies indicates an expected call of GetAllProxies. -func (mr *MockStoreMockRecorder) GetAllProxies(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAllProxies(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllProxies", reflect.TypeOf((*MockStore)(nil).GetAllProxies), ctx) } @@ -1844,7 +1850,7 @@ func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength Lo } // GetAllProxyAccessTokens indicates an expected call of GetAllProxyAccessTokens. -func (mr *MockStoreMockRecorder) GetAllProxyAccessTokens(ctx, lockStrength interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAllProxyAccessTokens(ctx, lockStrength any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllProxyAccessTokens", reflect.TypeOf((*MockStore)(nil).GetAllProxyAccessTokens), ctx, lockStrength) } @@ -1859,7 +1865,7 @@ func (m *MockStore) GetAnyAccountID(ctx context.Context) (string, error) { } // GetAnyAccountID indicates an expected call of GetAnyAccountID. -func (mr *MockStoreMockRecorder) GetAnyAccountID(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAnyAccountID(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnyAccountID", reflect.TypeOf((*MockStore)(nil).GetAnyAccountID), ctx) } @@ -1873,7 +1879,7 @@ func (m *MockStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr } // GetClusterRequireSubdomain indicates an expected call of GetClusterRequireSubdomain. -func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterRequireSubdomain", reflect.TypeOf((*MockStore)(nil).GetClusterRequireSubdomain), ctx, clusterAddr) } @@ -1887,7 +1893,7 @@ func (m *MockStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr } // GetClusterSupportsCrowdSec indicates an expected call of GetClusterSupportsCrowdSec. -func (mr *MockStoreMockRecorder) GetClusterSupportsCrowdSec(ctx, clusterAddr interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetClusterSupportsCrowdSec(ctx, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCrowdSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCrowdSec), ctx, clusterAddr) } @@ -1901,7 +1907,7 @@ func (m *MockStore) GetClusterSupportsCustomPorts(ctx context.Context, clusterAd } // GetClusterSupportsCustomPorts indicates an expected call of GetClusterSupportsCustomPorts. -func (mr *MockStoreMockRecorder) GetClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetClusterSupportsCustomPorts(ctx, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCustomPorts", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCustomPorts), ctx, clusterAddr) } @@ -1915,7 +1921,7 @@ func (m *MockStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr s } // GetClusterSupportsPrivate indicates an expected call of GetClusterSupportsPrivate. -func (mr *MockStoreMockRecorder) GetClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetClusterSupportsPrivate(ctx, clusterAddr any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsPrivate", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsPrivate), ctx, clusterAddr) } @@ -1930,7 +1936,7 @@ func (m *MockStore) GetCustomDomain(ctx context.Context, accountID, domainID str } // GetCustomDomain indicates an expected call of GetCustomDomain. -func (mr *MockStoreMockRecorder) GetCustomDomain(ctx, accountID, domainID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetCustomDomain(ctx, accountID, domainID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomain", reflect.TypeOf((*MockStore)(nil).GetCustomDomain), ctx, accountID, domainID) } @@ -1946,7 +1952,7 @@ func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, e } // GetCustomDomainsCounts indicates an expected call of GetCustomDomainsCounts. -func (mr *MockStoreMockRecorder) GetCustomDomainsCounts(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetCustomDomainsCounts(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomainsCounts", reflect.TypeOf((*MockStore)(nil).GetCustomDomainsCounts), ctx) } @@ -1961,7 +1967,7 @@ func (m *MockStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingSt } // GetDNSRecordByID indicates an expected call of GetDNSRecordByID. -func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID, zoneID, recordID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID, zoneID, recordID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSRecordByID", reflect.TypeOf((*MockStore)(nil).GetDNSRecordByID), ctx, lockStrength, accountID, zoneID, recordID) } @@ -1976,7 +1982,7 @@ func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accoun } // GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster. -func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID) } @@ -1991,7 +1997,7 @@ func (m *MockStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Du } // GetExpiredEphemeralServices indicates an expected call of GetExpiredEphemeralServices. -func (mr *MockStoreMockRecorder) GetExpiredEphemeralServices(ctx, ttl, limit interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetExpiredEphemeralServices(ctx, ttl, limit any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExpiredEphemeralServices", reflect.TypeOf((*MockStore)(nil).GetExpiredEphemeralServices), ctx, ttl, limit) } @@ -2006,7 +2012,7 @@ func (m *MockStore) GetGroupByID(ctx context.Context, lockStrength LockingStreng } // GetGroupByID indicates an expected call of GetGroupByID. -func (mr *MockStoreMockRecorder) GetGroupByID(ctx, lockStrength, accountID, groupID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetGroupByID(ctx, lockStrength, accountID, groupID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByID", reflect.TypeOf((*MockStore)(nil).GetGroupByID), ctx, lockStrength, accountID, groupID) } @@ -2021,7 +2027,7 @@ func (m *MockStore) GetGroupByName(ctx context.Context, lockStrength LockingStre } // GetGroupByName indicates an expected call of GetGroupByName. -func (mr *MockStoreMockRecorder) GetGroupByName(ctx, lockStrength, accountID, groupName interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetGroupByName(ctx, lockStrength, accountID, groupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockStore)(nil).GetGroupByName), ctx, lockStrength, accountID, groupName) } @@ -2036,7 +2042,7 @@ func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, } // GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs. -func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs) } @@ -2051,7 +2057,7 @@ func (m *MockStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStre } // GetGroupsByIDs indicates an expected call of GetGroupsByIDs. -func (mr *MockStoreMockRecorder) GetGroupsByIDs(ctx, lockStrength, accountID, groupIDs interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetGroupsByIDs(ctx, lockStrength, accountID, groupIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByIDs", reflect.TypeOf((*MockStore)(nil).GetGroupsByIDs), ctx, lockStrength, accountID, groupIDs) } @@ -2080,7 +2086,7 @@ func (m *MockStore) GetNameServerGroupByID(ctx context.Context, lockStrength Loc } // GetNameServerGroupByID indicates an expected call of GetNameServerGroupByID. -func (mr *MockStoreMockRecorder) GetNameServerGroupByID(ctx, lockStrength, nameServerGroupID, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNameServerGroupByID(ctx, lockStrength, nameServerGroupID, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNameServerGroupByID", reflect.TypeOf((*MockStore)(nil).GetNameServerGroupByID), ctx, lockStrength, nameServerGroupID, accountID) } @@ -2095,7 +2101,7 @@ func (m *MockStore) GetNetworkByID(ctx context.Context, lockStrength LockingStre } // GetNetworkByID indicates an expected call of GetNetworkByID. -func (mr *MockStoreMockRecorder) GetNetworkByID(ctx, lockStrength, accountID, networkID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkByID(ctx, lockStrength, accountID, networkID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkByID", reflect.TypeOf((*MockStore)(nil).GetNetworkByID), ctx, lockStrength, accountID, networkID) } @@ -2110,7 +2116,7 @@ func (m *MockStore) GetNetworkResourceByID(ctx context.Context, lockStrength Loc } // GetNetworkResourceByID indicates an expected call of GetNetworkResourceByID. -func (mr *MockStoreMockRecorder) GetNetworkResourceByID(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkResourceByID(ctx, lockStrength, accountID, resourceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByID), ctx, lockStrength, accountID, resourceID) } @@ -2125,7 +2131,7 @@ func (m *MockStore) GetNetworkResourceByName(ctx context.Context, lockStrength L } // GetNetworkResourceByName indicates an expected call of GetNetworkResourceByName. -func (mr *MockStoreMockRecorder) GetNetworkResourceByName(ctx, lockStrength, accountID, resourceName interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkResourceByName(ctx, lockStrength, accountID, resourceName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByName", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByName), ctx, lockStrength, accountID, resourceName) } @@ -2140,7 +2146,7 @@ func (m *MockStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStre } // GetNetworkResourcesByAccountID indicates an expected call of GetNetworkResourcesByAccountID. -func (mr *MockStoreMockRecorder) GetNetworkResourcesByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkResourcesByAccountID(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourcesByAccountID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourcesByAccountID), ctx, lockStrength, accountID) } @@ -2155,7 +2161,7 @@ func (m *MockStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength } // GetNetworkResourcesByNetID indicates an expected call of GetNetworkResourcesByNetID. -func (mr *MockStoreMockRecorder) GetNetworkResourcesByNetID(ctx, lockStrength, accountID, netID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkResourcesByNetID(ctx, lockStrength, accountID, netID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourcesByNetID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourcesByNetID), ctx, lockStrength, accountID, netID) } @@ -2170,7 +2176,7 @@ func (m *MockStore) GetNetworkRouterByID(ctx context.Context, lockStrength Locki } // GetNetworkRouterByID indicates an expected call of GetNetworkRouterByID. -func (mr *MockStoreMockRecorder) GetNetworkRouterByID(ctx, lockStrength, accountID, routerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkRouterByID(ctx, lockStrength, accountID, routerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRouterByID", reflect.TypeOf((*MockStore)(nil).GetNetworkRouterByID), ctx, lockStrength, accountID, routerID) } @@ -2185,7 +2191,7 @@ func (m *MockStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStreng } // GetNetworkRoutersByAccountID indicates an expected call of GetNetworkRoutersByAccountID. -func (mr *MockStoreMockRecorder) GetNetworkRoutersByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkRoutersByAccountID(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRoutersByAccountID", reflect.TypeOf((*MockStore)(nil).GetNetworkRoutersByAccountID), ctx, lockStrength, accountID) } @@ -2200,7 +2206,7 @@ func (m *MockStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength L } // GetNetworkRoutersByNetID indicates an expected call of GetNetworkRoutersByNetID. -func (mr *MockStoreMockRecorder) GetNetworkRoutersByNetID(ctx, lockStrength, accountID, netID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkRoutersByNetID(ctx, lockStrength, accountID, netID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRoutersByNetID", reflect.TypeOf((*MockStore)(nil).GetNetworkRoutersByNetID), ctx, lockStrength, accountID, netID) } @@ -2215,7 +2221,7 @@ func (m *MockStore) GetPATByHashedToken(ctx context.Context, lockStrength Lockin } // GetPATByHashedToken indicates an expected call of GetPATByHashedToken. -func (mr *MockStoreMockRecorder) GetPATByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPATByHashedToken(ctx, lockStrength, hashedToken any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPATByHashedToken", reflect.TypeOf((*MockStore)(nil).GetPATByHashedToken), ctx, lockStrength, hashedToken) } @@ -2230,7 +2236,7 @@ func (m *MockStore) GetPATByID(ctx context.Context, lockStrength LockingStrength } // GetPATByID indicates an expected call of GetPATByID. -func (mr *MockStoreMockRecorder) GetPATByID(ctx, lockStrength, userID, patID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPATByID(ctx, lockStrength, userID, patID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPATByID", reflect.TypeOf((*MockStore)(nil).GetPATByID), ctx, lockStrength, userID, patID) } @@ -2245,7 +2251,7 @@ func (m *MockStore) GetPeerByID(ctx context.Context, lockStrength LockingStrengt } // GetPeerByID indicates an expected call of GetPeerByID. -func (mr *MockStoreMockRecorder) GetPeerByID(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerByID(ctx, lockStrength, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByID", reflect.TypeOf((*MockStore)(nil).GetPeerByID), ctx, lockStrength, accountID, peerID) } @@ -2260,7 +2266,7 @@ func (m *MockStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrengt } // GetPeerByIP indicates an expected call of GetPeerByIP. -func (mr *MockStoreMockRecorder) GetPeerByIP(ctx, lockStrength, accountID, ip interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerByIP(ctx, lockStrength, accountID, ip any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByIP", reflect.TypeOf((*MockStore)(nil).GetPeerByIP), ctx, lockStrength, accountID, ip) } @@ -2275,7 +2281,7 @@ func (m *MockStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Lockin } // GetPeerByPeerPubKey indicates an expected call of GetPeerByPeerPubKey. -func (mr *MockStoreMockRecorder) GetPeerByPeerPubKey(ctx, lockStrength, peerKey interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerByPeerPubKey(ctx, lockStrength, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetPeerByPeerPubKey), ctx, lockStrength, peerKey) } @@ -2290,7 +2296,7 @@ func (m *MockStore) GetPeerGroupIDs(ctx context.Context, lockStrength LockingStr } // GetPeerGroupIDs indicates an expected call of GetPeerGroupIDs. -func (mr *MockStoreMockRecorder) GetPeerGroupIDs(ctx, lockStrength, accountId, peerId interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerGroupIDs(ctx, lockStrength, accountId, peerId any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeerGroupIDs), ctx, lockStrength, accountId, peerId) } @@ -2305,7 +2311,7 @@ func (m *MockStore) GetPeerGroups(ctx context.Context, lockStrength LockingStren } // GetPeerGroups indicates an expected call of GetPeerGroups. -func (mr *MockStoreMockRecorder) GetPeerGroups(ctx, lockStrength, accountId, peerId interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerGroups(ctx, lockStrength, accountId, peerId any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroups", reflect.TypeOf((*MockStore)(nil).GetPeerGroups), ctx, lockStrength, accountId, peerId) } @@ -2320,7 +2326,7 @@ func (m *MockStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStre } // GetPeerIDByKey indicates an expected call of GetPeerIDByKey. -func (mr *MockStoreMockRecorder) GetPeerIDByKey(ctx, lockStrength, key interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerIDByKey(ctx, lockStrength, key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDByKey", reflect.TypeOf((*MockStore)(nil).GetPeerIDByKey), ctx, lockStrength, key) } @@ -2335,7 +2341,7 @@ func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, gr } // GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups. -func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs) } @@ -2350,7 +2356,7 @@ func (m *MockStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingSt } // GetPeerIdByLabel indicates an expected call of GetPeerIdByLabel. -func (mr *MockStoreMockRecorder) GetPeerIdByLabel(ctx, lockStrength, accountID, hostname interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerIdByLabel(ctx, lockStrength, accountID, hostname any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIdByLabel", reflect.TypeOf((*MockStore)(nil).GetPeerIdByLabel), ctx, lockStrength, accountID, hostname) } @@ -2365,7 +2371,7 @@ func (m *MockStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) } // GetPeerJobByID indicates an expected call of GetPeerJobByID. -func (mr *MockStoreMockRecorder) GetPeerJobByID(ctx, accountID, jobID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerJobByID(ctx, accountID, jobID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobByID", reflect.TypeOf((*MockStore)(nil).GetPeerJobByID), ctx, accountID, jobID) } @@ -2380,7 +2386,7 @@ func (m *MockStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ( } // GetPeerJobs indicates an expected call of GetPeerJobs. -func (mr *MockStoreMockRecorder) GetPeerJobs(ctx, accountID, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerJobs(ctx, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobs", reflect.TypeOf((*MockStore)(nil).GetPeerJobs), ctx, accountID, peerID) } @@ -2395,7 +2401,7 @@ func (m *MockStore) GetPeerLabelsInAccount(ctx context.Context, lockStrength Loc } // GetPeerLabelsInAccount indicates an expected call of GetPeerLabelsInAccount. -func (mr *MockStoreMockRecorder) GetPeerLabelsInAccount(ctx, lockStrength, accountId, hostname interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeerLabelsInAccount(ctx, lockStrength, accountId, hostname any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerLabelsInAccount", reflect.TypeOf((*MockStore)(nil).GetPeerLabelsInAccount), ctx, lockStrength, accountId, hostname) } @@ -2410,7 +2416,7 @@ func (m *MockStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gr } // GetPeersByGroupIDs indicates an expected call of GetPeersByGroupIDs. -func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs) } @@ -2425,7 +2431,7 @@ func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStren } // GetPeersByIDs indicates an expected call of GetPeersByIDs. -func (mr *MockStoreMockRecorder) GetPeersByIDs(ctx, lockStrength, accountID, peerIDs interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPeersByIDs(ctx, lockStrength, accountID, peerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByIDs), ctx, lockStrength, accountID, peerIDs) } @@ -2440,7 +2446,7 @@ func (m *MockStore) GetPolicyByID(ctx context.Context, lockStrength LockingStren } // GetPolicyByID indicates an expected call of GetPolicyByID. -func (mr *MockStoreMockRecorder) GetPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPolicyByID(ctx, lockStrength, accountID, policyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyByID", reflect.TypeOf((*MockStore)(nil).GetPolicyByID), ctx, lockStrength, accountID, policyID) } @@ -2455,7 +2461,7 @@ func (m *MockStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength } // GetPolicyRulesByResourceID indicates an expected call of GetPolicyRulesByResourceID. -func (mr *MockStoreMockRecorder) GetPolicyRulesByResourceID(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPolicyRulesByResourceID(ctx, lockStrength, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyRulesByResourceID", reflect.TypeOf((*MockStore)(nil).GetPolicyRulesByResourceID), ctx, lockStrength, accountID, peerID) } @@ -2470,7 +2476,7 @@ func (m *MockStore) GetPostureCheckByChecksDefinition(accountID string, checks * } // GetPostureCheckByChecksDefinition indicates an expected call of GetPostureCheckByChecksDefinition. -func (mr *MockStoreMockRecorder) GetPostureCheckByChecksDefinition(accountID, checks interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPostureCheckByChecksDefinition(accountID, checks any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureCheckByChecksDefinition", reflect.TypeOf((*MockStore)(nil).GetPostureCheckByChecksDefinition), accountID, checks) } @@ -2485,7 +2491,7 @@ func (m *MockStore) GetPostureChecksByID(ctx context.Context, lockStrength Locki } // GetPostureChecksByID indicates an expected call of GetPostureChecksByID. -func (mr *MockStoreMockRecorder) GetPostureChecksByID(ctx, lockStrength, accountID, postureCheckID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPostureChecksByID(ctx, lockStrength, accountID, postureCheckID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecksByID", reflect.TypeOf((*MockStore)(nil).GetPostureChecksByID), ctx, lockStrength, accountID, postureCheckID) } @@ -2500,7 +2506,7 @@ func (m *MockStore) GetPostureChecksByIDs(ctx context.Context, lockStrength Lock } // GetPostureChecksByIDs indicates an expected call of GetPostureChecksByIDs. -func (mr *MockStoreMockRecorder) GetPostureChecksByIDs(ctx, lockStrength, accountID, postureChecksIDs interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPostureChecksByIDs(ctx, lockStrength, accountID, postureChecksIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecksByIDs", reflect.TypeOf((*MockStore)(nil).GetPostureChecksByIDs), ctx, lockStrength, accountID, postureChecksIDs) } @@ -2515,7 +2521,7 @@ func (m *MockStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockSt } // GetProxyAccessTokenByHashedToken indicates an expected call of GetProxyAccessTokenByHashedToken. -func (mr *MockStoreMockRecorder) GetProxyAccessTokenByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetProxyAccessTokenByHashedToken(ctx, lockStrength, hashedToken any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyAccessTokenByHashedToken", reflect.TypeOf((*MockStore)(nil).GetProxyAccessTokenByHashedToken), ctx, lockStrength, hashedToken) } @@ -2530,7 +2536,7 @@ func (m *MockStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength Lo } // GetProxyAccessTokenByID indicates an expected call of GetProxyAccessTokenByID. -func (mr *MockStoreMockRecorder) GetProxyAccessTokenByID(ctx, lockStrength, tokenID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetProxyAccessTokenByID(ctx, lockStrength, tokenID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyAccessTokenByID", reflect.TypeOf((*MockStore)(nil).GetProxyAccessTokenByID), ctx, lockStrength, tokenID) } @@ -2545,7 +2551,7 @@ func (m *MockStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStr } // GetProxyAccessTokensByAccountID indicates an expected call of GetProxyAccessTokensByAccountID. -func (mr *MockStoreMockRecorder) GetProxyAccessTokensByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetProxyAccessTokensByAccountID(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyAccessTokensByAccountID", reflect.TypeOf((*MockStore)(nil).GetProxyAccessTokensByAccountID), ctx, lockStrength, accountID) } @@ -2560,7 +2566,7 @@ func (m *MockStore) GetProxyByAccountID(ctx context.Context, accountID string) ( } // GetProxyByAccountID indicates an expected call of GetProxyByAccountID. -func (mr *MockStoreMockRecorder) GetProxyByAccountID(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetProxyByAccountID(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyByAccountID", reflect.TypeOf((*MockStore)(nil).GetProxyByAccountID), ctx, accountID) } @@ -2575,7 +2581,7 @@ func (m *MockStore) GetProxyClusters(ctx context.Context, accountID string) ([]p } // GetProxyClusters indicates an expected call of GetProxyClusters. -func (mr *MockStoreMockRecorder) GetProxyClusters(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetProxyClusters(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyClusters", reflect.TypeOf((*MockStore)(nil).GetProxyClusters), ctx, accountID) } @@ -2590,7 +2596,7 @@ func (m *MockStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) { } // GetProxyMetrics indicates an expected call of GetProxyMetrics. -func (mr *MockStoreMockRecorder) GetProxyMetrics(ctx interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetProxyMetrics(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyMetrics", reflect.TypeOf((*MockStore)(nil).GetProxyMetrics), ctx) } @@ -2605,7 +2611,7 @@ func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingS } // GetResourceGroups indicates an expected call of GetResourceGroups. -func (mr *MockStoreMockRecorder) GetResourceGroups(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetResourceGroups(ctx, lockStrength, accountID, resourceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourceGroups", reflect.TypeOf((*MockStore)(nil).GetResourceGroups), ctx, lockStrength, accountID, resourceID) } @@ -2620,7 +2626,7 @@ func (m *MockStore) GetRouteByID(ctx context.Context, lockStrength LockingStreng } // GetRouteByID indicates an expected call of GetRouteByID. -func (mr *MockStoreMockRecorder) GetRouteByID(ctx, lockStrength, accountID, routeID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetRouteByID(ctx, lockStrength, accountID, routeID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRouteByID", reflect.TypeOf((*MockStore)(nil).GetRouteByID), ctx, lockStrength, accountID, routeID) } @@ -2635,24 +2641,24 @@ func (m *MockStore) GetRoutingPeerNetworks(ctx context.Context, accountID, peerI } // GetRoutingPeerNetworks indicates an expected call of GetRoutingPeerNetworks. -func (mr *MockStoreMockRecorder) GetRoutingPeerNetworks(ctx, accountID, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetRoutingPeerNetworks(ctx, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoutingPeerNetworks", reflect.TypeOf((*MockStore)(nil).GetRoutingPeerNetworks), ctx, accountID, peerID) } // GetServiceByDomain mocks base method. -func (m *MockStore) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) { +func (m *MockStore) GetServiceByDomain(ctx context.Context, arg1 string) (*service.Service, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetServiceByDomain", ctx, domain) + ret := m.ctrl.Call(m, "GetServiceByDomain", ctx, arg1) ret0, _ := ret[0].(*service.Service) ret1, _ := ret[1].(error) return ret0, ret1 } // GetServiceByDomain indicates an expected call of GetServiceByDomain. -func (mr *MockStoreMockRecorder) GetServiceByDomain(ctx, domain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetServiceByDomain(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByDomain", reflect.TypeOf((*MockStore)(nil).GetServiceByDomain), ctx, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByDomain", reflect.TypeOf((*MockStore)(nil).GetServiceByDomain), ctx, arg1) } // GetServiceByID mocks base method. @@ -2665,7 +2671,7 @@ func (m *MockStore) GetServiceByID(ctx context.Context, lockStrength LockingStre } // GetServiceByID indicates an expected call of GetServiceByID. -func (mr *MockStoreMockRecorder) GetServiceByID(ctx, lockStrength, accountID, serviceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetServiceByID(ctx, lockStrength, accountID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByID", reflect.TypeOf((*MockStore)(nil).GetServiceByID), ctx, lockStrength, accountID, serviceID) } @@ -2680,7 +2686,7 @@ func (m *MockStore) GetServiceTargetByTargetID(ctx context.Context, lockStrength } // GetServiceTargetByTargetID indicates an expected call of GetServiceTargetByTargetID. -func (mr *MockStoreMockRecorder) GetServiceTargetByTargetID(ctx, lockStrength, accountID, targetID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetServiceTargetByTargetID(ctx, lockStrength, accountID, targetID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceTargetByTargetID", reflect.TypeOf((*MockStore)(nil).GetServiceTargetByTargetID), ctx, lockStrength, accountID, targetID) } @@ -2695,7 +2701,7 @@ func (m *MockStore) GetServices(ctx context.Context, lockStrength LockingStrengt } // GetServices indicates an expected call of GetServices. -func (mr *MockStoreMockRecorder) GetServices(ctx, lockStrength interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetServices(ctx, lockStrength any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServices", reflect.TypeOf((*MockStore)(nil).GetServices), ctx, lockStrength) } @@ -2710,7 +2716,7 @@ func (m *MockStore) GetServicesByCluster(ctx context.Context, lockStrength Locki } // GetServicesByCluster indicates an expected call of GetServicesByCluster. -func (mr *MockStoreMockRecorder) GetServicesByCluster(ctx, lockStrength, proxyCluster interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetServicesByCluster(ctx, lockStrength, proxyCluster any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByCluster", reflect.TypeOf((*MockStore)(nil).GetServicesByCluster), ctx, lockStrength, proxyCluster) } @@ -2725,7 +2731,7 @@ func (m *MockStore) GetServicesByClusterAndPort(ctx context.Context, lockStrengt } // GetServicesByClusterAndPort indicates an expected call of GetServicesByClusterAndPort. -func (mr *MockStoreMockRecorder) GetServicesByClusterAndPort(ctx, lockStrength, proxyCluster, mode, listenPort interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetServicesByClusterAndPort(ctx, lockStrength, proxyCluster, mode, listenPort any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByClusterAndPort", reflect.TypeOf((*MockStore)(nil).GetServicesByClusterAndPort), ctx, lockStrength, proxyCluster, mode, listenPort) } @@ -2740,7 +2746,7 @@ func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStr } // GetSetupKeyByID indicates an expected call of GetSetupKeyByID. -func (mr *MockStoreMockRecorder) GetSetupKeyByID(ctx, lockStrength, accountID, setupKeyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetSetupKeyByID(ctx, lockStrength, accountID, setupKeyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKeyByID", reflect.TypeOf((*MockStore)(nil).GetSetupKeyByID), ctx, lockStrength, accountID, setupKeyID) } @@ -2755,7 +2761,7 @@ func (m *MockStore) GetSetupKeyBySecret(ctx context.Context, lockStrength Lockin } // GetSetupKeyBySecret indicates an expected call of GetSetupKeyBySecret. -func (mr *MockStoreMockRecorder) GetSetupKeyBySecret(ctx, lockStrength, key interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetSetupKeyBySecret(ctx, lockStrength, key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKeyBySecret", reflect.TypeOf((*MockStore)(nil).GetSetupKeyBySecret), ctx, lockStrength, key) } @@ -2784,7 +2790,7 @@ func (m *MockStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrengt } // GetTakenIPs indicates an expected call of GetTakenIPs. -func (mr *MockStoreMockRecorder) GetTakenIPs(ctx, lockStrength, accountId interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetTakenIPs(ctx, lockStrength, accountId any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTakenIPs", reflect.TypeOf((*MockStore)(nil).GetTakenIPs), ctx, lockStrength, accountId) } @@ -2799,7 +2805,7 @@ func (m *MockStore) GetTargetsByServiceID(ctx context.Context, lockStrength Lock } // GetTargetsByServiceID indicates an expected call of GetTargetsByServiceID. -func (mr *MockStoreMockRecorder) GetTargetsByServiceID(ctx, lockStrength, accountID, serviceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetTargetsByServiceID(ctx, lockStrength, accountID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetsByServiceID", reflect.TypeOf((*MockStore)(nil).GetTargetsByServiceID), ctx, lockStrength, accountID, serviceID) } @@ -2814,7 +2820,7 @@ func (m *MockStore) GetTokenIDByHashedToken(ctx context.Context, secret string) } // GetTokenIDByHashedToken indicates an expected call of GetTokenIDByHashedToken. -func (mr *MockStoreMockRecorder) GetTokenIDByHashedToken(ctx, secret interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetTokenIDByHashedToken(ctx, secret any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTokenIDByHashedToken", reflect.TypeOf((*MockStore)(nil).GetTokenIDByHashedToken), ctx, secret) } @@ -2829,7 +2835,7 @@ func (m *MockStore) GetUserByPATID(ctx context.Context, lockStrength LockingStre } // GetUserByPATID indicates an expected call of GetUserByPATID. -func (mr *MockStoreMockRecorder) GetUserByPATID(ctx, lockStrength, patID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserByPATID(ctx, lockStrength, patID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByPATID", reflect.TypeOf((*MockStore)(nil).GetUserByPATID), ctx, lockStrength, patID) } @@ -2844,7 +2850,7 @@ func (m *MockStore) GetUserByUserID(ctx context.Context, lockStrength LockingStr } // GetUserByUserID indicates an expected call of GetUserByUserID. -func (mr *MockStoreMockRecorder) GetUserByUserID(ctx, lockStrength, userID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserByUserID(ctx, lockStrength, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUserID", reflect.TypeOf((*MockStore)(nil).GetUserByUserID), ctx, lockStrength, userID) } @@ -2859,7 +2865,7 @@ func (m *MockStore) GetUserIDByPeerKey(ctx context.Context, lockStrength Locking } // GetUserIDByPeerKey indicates an expected call of GetUserIDByPeerKey. -func (mr *MockStoreMockRecorder) GetUserIDByPeerKey(ctx, lockStrength, peerKey interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserIDByPeerKey(ctx, lockStrength, peerKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserIDByPeerKey", reflect.TypeOf((*MockStore)(nil).GetUserIDByPeerKey), ctx, lockStrength, peerKey) } @@ -2874,7 +2880,7 @@ func (m *MockStore) GetUserInviteByEmail(ctx context.Context, lockStrength Locki } // GetUserInviteByEmail indicates an expected call of GetUserInviteByEmail. -func (mr *MockStoreMockRecorder) GetUserInviteByEmail(ctx, lockStrength, accountID, email interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserInviteByEmail(ctx, lockStrength, accountID, email any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByEmail", reflect.TypeOf((*MockStore)(nil).GetUserInviteByEmail), ctx, lockStrength, accountID, email) } @@ -2889,7 +2895,7 @@ func (m *MockStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength } // GetUserInviteByHashedToken indicates an expected call of GetUserInviteByHashedToken. -func (mr *MockStoreMockRecorder) GetUserInviteByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserInviteByHashedToken(ctx, lockStrength, hashedToken any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByHashedToken", reflect.TypeOf((*MockStore)(nil).GetUserInviteByHashedToken), ctx, lockStrength, hashedToken) } @@ -2904,7 +2910,7 @@ func (m *MockStore) GetUserInviteByID(ctx context.Context, lockStrength LockingS } // GetUserInviteByID indicates an expected call of GetUserInviteByID. -func (mr *MockStoreMockRecorder) GetUserInviteByID(ctx, lockStrength, accountID, inviteID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserInviteByID(ctx, lockStrength, accountID, inviteID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByID", reflect.TypeOf((*MockStore)(nil).GetUserInviteByID), ctx, lockStrength, accountID, inviteID) } @@ -2919,7 +2925,7 @@ func (m *MockStore) GetUserPATs(ctx context.Context, lockStrength LockingStrengt } // GetUserPATs indicates an expected call of GetUserPATs. -func (mr *MockStoreMockRecorder) GetUserPATs(ctx, lockStrength, userID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserPATs(ctx, lockStrength, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPATs", reflect.TypeOf((*MockStore)(nil).GetUserPATs), ctx, lockStrength, userID) } @@ -2934,24 +2940,24 @@ func (m *MockStore) GetUserPeers(ctx context.Context, lockStrength LockingStreng } // GetUserPeers indicates an expected call of GetUserPeers. -func (mr *MockStoreMockRecorder) GetUserPeers(ctx, lockStrength, accountID, userID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetUserPeers(ctx, lockStrength, accountID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPeers", reflect.TypeOf((*MockStore)(nil).GetUserPeers), ctx, lockStrength, accountID, userID) } // GetZoneByDomain mocks base method. -func (m *MockStore) GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) { +func (m *MockStore) GetZoneByDomain(ctx context.Context, accountID, arg2 string) (*zones.Zone, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetZoneByDomain", ctx, accountID, domain) + ret := m.ctrl.Call(m, "GetZoneByDomain", ctx, accountID, arg2) ret0, _ := ret[0].(*zones.Zone) ret1, _ := ret[1].(error) return ret0, ret1 } // GetZoneByDomain indicates an expected call of GetZoneByDomain. -func (mr *MockStoreMockRecorder) GetZoneByDomain(ctx, accountID, domain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetZoneByDomain(ctx, accountID, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneByDomain", reflect.TypeOf((*MockStore)(nil).GetZoneByDomain), ctx, accountID, domain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneByDomain", reflect.TypeOf((*MockStore)(nil).GetZoneByDomain), ctx, accountID, arg2) } // GetZoneByID mocks base method. @@ -2964,7 +2970,7 @@ func (m *MockStore) GetZoneByID(ctx context.Context, lockStrength LockingStrengt } // GetZoneByID indicates an expected call of GetZoneByID. -func (mr *MockStoreMockRecorder) GetZoneByID(ctx, lockStrength, accountID, zoneID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetZoneByID(ctx, lockStrength, accountID, zoneID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneByID", reflect.TypeOf((*MockStore)(nil).GetZoneByID), ctx, lockStrength, accountID, zoneID) } @@ -2979,7 +2985,7 @@ func (m *MockStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingS } // GetZoneDNSRecords indicates an expected call of GetZoneDNSRecords. -func (mr *MockStoreMockRecorder) GetZoneDNSRecords(ctx, lockStrength, accountID, zoneID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetZoneDNSRecords(ctx, lockStrength, accountID, zoneID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecords), ctx, lockStrength, accountID, zoneID) } @@ -2994,7 +3000,7 @@ func (m *MockStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength Lo } // GetZoneDNSRecordsByName indicates an expected call of GetZoneDNSRecordsByName. -func (mr *MockStoreMockRecorder) GetZoneDNSRecordsByName(ctx, lockStrength, accountID, zoneID, name interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetZoneDNSRecordsByName(ctx, lockStrength, accountID, zoneID, name any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecordsByName", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecordsByName), ctx, lockStrength, accountID, zoneID, name) } @@ -3009,7 +3015,7 @@ func (m *MockStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterA } // HasActiveProxyAtClusterAddress indicates an expected call of HasActiveProxyAtClusterAddress. -func (mr *MockStoreMockRecorder) HasActiveProxyAtClusterAddress(ctx, clusterAddress interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) HasActiveProxyAtClusterAddress(ctx, clusterAddress any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasActiveProxyAtClusterAddress", reflect.TypeOf((*MockStore)(nil).HasActiveProxyAtClusterAddress), ctx, clusterAddress) } @@ -3023,7 +3029,7 @@ func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accoun } // IncrementAgentNetworkConsumption indicates an expected call of IncrementAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD any) *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) } @@ -3037,7 +3043,7 @@ func (m *MockStore) IncrementAgentNetworkConsumptionBatch(ctx context.Context, a } // IncrementAgentNetworkConsumptionBatch indicates an expected call of IncrementAgentNetworkConsumptionBatch. -func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD any) *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) } @@ -3051,7 +3057,7 @@ func (m *MockStore) IncrementNetworkSerial(ctx context.Context, accountId string } // IncrementNetworkSerial indicates an expected call of IncrementNetworkSerial. -func (mr *MockStoreMockRecorder) IncrementNetworkSerial(ctx, accountId interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) IncrementNetworkSerial(ctx, accountId any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementNetworkSerial", reflect.TypeOf((*MockStore)(nil).IncrementNetworkSerial), ctx, accountId) } @@ -3065,7 +3071,7 @@ func (m *MockStore) IncrementSetupKeyUsage(ctx context.Context, setupKeyID strin } // IncrementSetupKeyUsage indicates an expected call of IncrementSetupKeyUsage. -func (mr *MockStoreMockRecorder) IncrementSetupKeyUsage(ctx, setupKeyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) IncrementSetupKeyUsage(ctx, setupKeyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementSetupKeyUsage", reflect.TypeOf((*MockStore)(nil).IncrementSetupKeyUsage), ctx, setupKeyID) } @@ -3080,7 +3086,7 @@ func (m *MockStore) IsClusterAddressConflicting(ctx context.Context, clusterAddr } // IsClusterAddressConflicting indicates an expected call of IsClusterAddressConflicting. -func (mr *MockStoreMockRecorder) IsClusterAddressConflicting(ctx, clusterAddress, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) IsClusterAddressConflicting(ctx, clusterAddress, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressConflicting", reflect.TypeOf((*MockStore)(nil).IsClusterAddressConflicting), ctx, clusterAddress, accountID) } @@ -3096,7 +3102,7 @@ func (m *MockStore) IsPrimaryAccount(ctx context.Context, accountID string) (boo } // IsPrimaryAccount indicates an expected call of IsPrimaryAccount. -func (mr *MockStoreMockRecorder) IsPrimaryAccount(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) IsPrimaryAccount(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsPrimaryAccount", reflect.TypeOf((*MockStore)(nil).IsPrimaryAccount), ctx, accountID) } @@ -3111,7 +3117,7 @@ func (m *MockStore) IsProxyAccessTokenValid(ctx context.Context, tokenID string) } // IsProxyAccessTokenValid indicates an expected call of IsProxyAccessTokenValid. -func (mr *MockStoreMockRecorder) IsProxyAccessTokenValid(ctx, tokenID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) IsProxyAccessTokenValid(ctx, tokenID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsProxyAccessTokenValid", reflect.TypeOf((*MockStore)(nil).IsProxyAccessTokenValid), ctx, tokenID) } @@ -3126,7 +3132,7 @@ func (m *MockStore) ListAgentNetworkConsumption(ctx context.Context, lockStrengt } // ListAgentNetworkConsumption indicates an expected call of ListAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID) } @@ -3141,7 +3147,7 @@ func (m *MockStore) ListCustomDomains(ctx context.Context, accountID string) ([] } // ListCustomDomains indicates an expected call of ListCustomDomains. -func (mr *MockStoreMockRecorder) ListCustomDomains(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) ListCustomDomains(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCustomDomains", reflect.TypeOf((*MockStore)(nil).ListCustomDomains), ctx, accountID) } @@ -3156,7 +3162,7 @@ func (m *MockStore) ListFreeDomains(ctx context.Context, accountID string) ([]st } // ListFreeDomains indicates an expected call of ListFreeDomains. -func (mr *MockStoreMockRecorder) ListFreeDomains(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) ListFreeDomains(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFreeDomains", reflect.TypeOf((*MockStore)(nil).ListFreeDomains), ctx, accountID) } @@ -3170,7 +3176,7 @@ func (m *MockStore) MarkAccountPrimary(ctx context.Context, accountID string) er } // MarkAccountPrimary indicates an expected call of MarkAccountPrimary. -func (mr *MockStoreMockRecorder) MarkAccountPrimary(ctx, accountID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) MarkAccountPrimary(ctx, accountID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAccountPrimary", reflect.TypeOf((*MockStore)(nil).MarkAccountPrimary), ctx, accountID) } @@ -3184,7 +3190,7 @@ func (m *MockStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, p } // MarkAllPendingJobsAsFailed indicates an expected call of MarkAllPendingJobsAsFailed. -func (mr *MockStoreMockRecorder) MarkAllPendingJobsAsFailed(ctx, accountID, peerID, reason interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) MarkAllPendingJobsAsFailed(ctx, accountID, peerID, reason any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAllPendingJobsAsFailed", reflect.TypeOf((*MockStore)(nil).MarkAllPendingJobsAsFailed), ctx, accountID, peerID, reason) } @@ -3198,7 +3204,7 @@ func (m *MockStore) MarkPATUsed(ctx context.Context, patID string) error { } // MarkPATUsed indicates an expected call of MarkPATUsed. -func (mr *MockStoreMockRecorder) MarkPATUsed(ctx, patID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) MarkPATUsed(ctx, patID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPATUsed", reflect.TypeOf((*MockStore)(nil).MarkPATUsed), ctx, patID) } @@ -3213,7 +3219,7 @@ func (m *MockStore) MarkPeerConnectedIfNewerSession(ctx context.Context, account } // MarkPeerConnectedIfNewerSession indicates an expected call of MarkPeerConnectedIfNewerSession. -func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnectedIfNewerSession", reflect.TypeOf((*MockStore)(nil).MarkPeerConnectedIfNewerSession), ctx, accountID, peerID, newSessionStartedAt) } @@ -3228,7 +3234,7 @@ func (m *MockStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accou } // MarkPeerDisconnectedIfSameSession indicates an expected call of MarkPeerDisconnectedIfSameSession. -func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnectedIfSameSession", reflect.TypeOf((*MockStore)(nil).MarkPeerDisconnectedIfSameSession), ctx, accountID, peerID, sessionStartedAt) } @@ -3242,7 +3248,7 @@ func (m *MockStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peer } // MarkPendingJobsAsFailed indicates an expected call of MarkPendingJobsAsFailed. -func (mr *MockStoreMockRecorder) MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, reason interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, reason any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPendingJobsAsFailed", reflect.TypeOf((*MockStore)(nil).MarkPendingJobsAsFailed), ctx, accountID, peerID, jobID, reason) } @@ -3256,7 +3262,7 @@ func (m *MockStore) MarkProxyAccessTokenUsed(ctx context.Context, tokenID string } // MarkProxyAccessTokenUsed indicates an expected call of MarkProxyAccessTokenUsed. -func (mr *MockStoreMockRecorder) MarkProxyAccessTokenUsed(ctx, tokenID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) MarkProxyAccessTokenUsed(ctx, tokenID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkProxyAccessTokenUsed", reflect.TypeOf((*MockStore)(nil).MarkProxyAccessTokenUsed), ctx, tokenID) } @@ -3271,7 +3277,7 @@ func (m *MockStore) RefreshPeerLastSeen(ctx context.Context, accountID, peerID s } // RefreshPeerLastSeen indicates an expected call of RefreshPeerLastSeen. -func (mr *MockStoreMockRecorder) RefreshPeerLastSeen(ctx, accountID, peerID, staleBefore interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) RefreshPeerLastSeen(ctx, accountID, peerID, staleBefore any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RefreshPeerLastSeen", reflect.TypeOf((*MockStore)(nil).RefreshPeerLastSeen), ctx, accountID, peerID, staleBefore) } @@ -3285,7 +3291,7 @@ func (m *MockStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) } // RemovePeerFromAllGroups indicates an expected call of RemovePeerFromAllGroups. -func (mr *MockStoreMockRecorder) RemovePeerFromAllGroups(ctx, peerID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) RemovePeerFromAllGroups(ctx, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePeerFromAllGroups", reflect.TypeOf((*MockStore)(nil).RemovePeerFromAllGroups), ctx, peerID) } @@ -3299,7 +3305,7 @@ func (m *MockStore) RemovePeerFromGroup(ctx context.Context, peerID, groupID str } // RemovePeerFromGroup indicates an expected call of RemovePeerFromGroup. -func (mr *MockStoreMockRecorder) RemovePeerFromGroup(ctx, peerID, groupID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) RemovePeerFromGroup(ctx, peerID, groupID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePeerFromGroup", reflect.TypeOf((*MockStore)(nil).RemovePeerFromGroup), ctx, peerID, groupID) } @@ -3313,7 +3319,7 @@ func (m *MockStore) RemoveResourceFromGroup(ctx context.Context, accountId, grou } // RemoveResourceFromGroup indicates an expected call of RemoveResourceFromGroup. -func (mr *MockStoreMockRecorder) RemoveResourceFromGroup(ctx, accountId, groupID, resourceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) RemoveResourceFromGroup(ctx, accountId, groupID, resourceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveResourceFromGroup", reflect.TypeOf((*MockStore)(nil).RemoveResourceFromGroup), ctx, accountId, groupID, resourceID) } @@ -3327,7 +3333,7 @@ func (m *MockStore) RenewEphemeralService(ctx context.Context, accountID, peerID } // RenewEphemeralService indicates an expected call of RenewEphemeralService. -func (mr *MockStoreMockRecorder) RenewEphemeralService(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) RenewEphemeralService(ctx, accountID, peerID, serviceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewEphemeralService", reflect.TypeOf((*MockStore)(nil).RenewEphemeralService), ctx, accountID, peerID, serviceID) } @@ -3341,7 +3347,7 @@ func (m *MockStore) RevokeProxyAccessToken(ctx context.Context, tokenID string) } // RevokeProxyAccessToken indicates an expected call of RevokeProxyAccessToken. -func (mr *MockStoreMockRecorder) RevokeProxyAccessToken(ctx, tokenID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) RevokeProxyAccessToken(ctx, tokenID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeProxyAccessToken", reflect.TypeOf((*MockStore)(nil).RevokeProxyAccessToken), ctx, tokenID) } @@ -3355,7 +3361,7 @@ func (m *MockStore) SaveAccount(ctx context.Context, account *types3.Account) er } // SaveAccount indicates an expected call of SaveAccount. -func (mr *MockStoreMockRecorder) SaveAccount(ctx, account interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAccount(ctx, account any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccount", reflect.TypeOf((*MockStore)(nil).SaveAccount), ctx, account) } @@ -3369,7 +3375,7 @@ func (m *MockStore) SaveAccountOnboarding(ctx context.Context, onboarding *types } // SaveAccountOnboarding indicates an expected call of SaveAccountOnboarding. -func (mr *MockStoreMockRecorder) SaveAccountOnboarding(ctx, onboarding interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAccountOnboarding(ctx, onboarding any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountOnboarding", reflect.TypeOf((*MockStore)(nil).SaveAccountOnboarding), ctx, onboarding) } @@ -3383,7 +3389,7 @@ func (m *MockStore) SaveAccountSettings(ctx context.Context, accountID string, s } // SaveAccountSettings indicates an expected call of SaveAccountSettings. -func (mr *MockStoreMockRecorder) SaveAccountSettings(ctx, accountID, settings interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAccountSettings(ctx, accountID, settings any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountSettings", reflect.TypeOf((*MockStore)(nil).SaveAccountSettings), ctx, accountID, settings) } @@ -3397,7 +3403,7 @@ func (m *MockStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *types. } // SaveAgentNetworkBudgetRule indicates an expected call of SaveAgentNetworkBudgetRule. -func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkBudgetRule), ctx, rule) } @@ -3411,7 +3417,7 @@ func (m *MockStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *ty } // SaveAgentNetworkGuardrail indicates an expected call of SaveAgentNetworkGuardrail. -func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkGuardrail), ctx, guardrail) } @@ -3425,7 +3431,7 @@ func (m *MockStore) SaveAgentNetworkPolicy(ctx context.Context, policy *types.Po } // SaveAgentNetworkPolicy indicates an expected call of SaveAgentNetworkPolicy. -func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkPolicy), ctx, policy) } @@ -3439,7 +3445,7 @@ func (m *MockStore) SaveAgentNetworkProvider(ctx context.Context, provider *type } // SaveAgentNetworkProvider indicates an expected call of SaveAgentNetworkProvider. -func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkProvider), ctx, provider) } @@ -3453,7 +3459,7 @@ func (m *MockStore) SaveAgentNetworkSettings(ctx context.Context, settings *type } // SaveAgentNetworkSettings indicates an expected call of SaveAgentNetworkSettings. -func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkSettings), ctx, settings) } @@ -3467,7 +3473,7 @@ func (m *MockStore) SaveDNSSettings(ctx context.Context, accountID string, setti } // SaveDNSSettings indicates an expected call of SaveDNSSettings. -func (mr *MockStoreMockRecorder) SaveDNSSettings(ctx, accountID, settings interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveDNSSettings(ctx, accountID, settings any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveDNSSettings", reflect.TypeOf((*MockStore)(nil).SaveDNSSettings), ctx, accountID, settings) } @@ -3481,7 +3487,7 @@ func (m *MockStore) SaveInstallationID(ctx context.Context, ID string) error { } // SaveInstallationID indicates an expected call of SaveInstallationID. -func (mr *MockStoreMockRecorder) SaveInstallationID(ctx, ID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveInstallationID(ctx, ID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveInstallationID", reflect.TypeOf((*MockStore)(nil).SaveInstallationID), ctx, ID) } @@ -3495,7 +3501,7 @@ func (m *MockStore) SaveNameServerGroup(ctx context.Context, nameServerGroup *dn } // SaveNameServerGroup indicates an expected call of SaveNameServerGroup. -func (mr *MockStoreMockRecorder) SaveNameServerGroup(ctx, nameServerGroup interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveNameServerGroup(ctx, nameServerGroup any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNameServerGroup", reflect.TypeOf((*MockStore)(nil).SaveNameServerGroup), ctx, nameServerGroup) } @@ -3509,7 +3515,7 @@ func (m *MockStore) SaveNetwork(ctx context.Context, network *types2.Network) er } // SaveNetwork indicates an expected call of SaveNetwork. -func (mr *MockStoreMockRecorder) SaveNetwork(ctx, network interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveNetwork(ctx, network any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetwork", reflect.TypeOf((*MockStore)(nil).SaveNetwork), ctx, network) } @@ -3523,7 +3529,7 @@ func (m *MockStore) SaveNetworkResource(ctx context.Context, resource *types0.Ne } // SaveNetworkResource indicates an expected call of SaveNetworkResource. -func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkResource", reflect.TypeOf((*MockStore)(nil).SaveNetworkResource), ctx, resource) } @@ -3537,23 +3543,23 @@ func (m *MockStore) SavePAT(ctx context.Context, pat *types3.PersonalAccessToken } // SavePAT indicates an expected call of SavePAT. -func (mr *MockStoreMockRecorder) SavePAT(ctx, pat interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SavePAT(ctx, pat any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePAT", reflect.TypeOf((*MockStore)(nil).SavePAT), ctx, pat) } // SavePeer mocks base method. -func (m *MockStore) SavePeer(ctx context.Context, accountID string, peer *peer.Peer) error { +func (m *MockStore) SavePeer(ctx context.Context, accountID string, arg2 *peer.Peer) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SavePeer", ctx, accountID, peer) + ret := m.ctrl.Call(m, "SavePeer", ctx, accountID, arg2) ret0, _ := ret[0].(error) return ret0 } // SavePeer indicates an expected call of SavePeer. -func (mr *MockStoreMockRecorder) SavePeer(ctx, accountID, peer interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SavePeer(ctx, accountID, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeer", reflect.TypeOf((*MockStore)(nil).SavePeer), ctx, accountID, peer) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeer", reflect.TypeOf((*MockStore)(nil).SavePeer), ctx, accountID, arg2) } // SavePeerStatus mocks base method. @@ -3565,7 +3571,7 @@ func (m *MockStore) SavePeerStatus(ctx context.Context, accountID, peerID string } // SavePeerStatus indicates an expected call of SavePeerStatus. -func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerStatus", reflect.TypeOf((*MockStore)(nil).SavePeerStatus), ctx, accountID, peerID, status) } @@ -3579,7 +3585,7 @@ func (m *MockStore) SavePolicy(ctx context.Context, policy *types3.Policy) error } // SavePolicy indicates an expected call of SavePolicy. -func (mr *MockStoreMockRecorder) SavePolicy(ctx, policy interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SavePolicy(ctx, policy any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePolicy", reflect.TypeOf((*MockStore)(nil).SavePolicy), ctx, policy) } @@ -3593,23 +3599,23 @@ func (m *MockStore) SavePostureChecks(ctx context.Context, postureCheck *posture } // SavePostureChecks indicates an expected call of SavePostureChecks. -func (mr *MockStoreMockRecorder) SavePostureChecks(ctx, postureCheck interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SavePostureChecks(ctx, postureCheck any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePostureChecks", reflect.TypeOf((*MockStore)(nil).SavePostureChecks), ctx, postureCheck) } // SaveProxy mocks base method. -func (m *MockStore) SaveProxy(ctx context.Context, proxy *proxy.Proxy) error { +func (m *MockStore) SaveProxy(ctx context.Context, arg1 *proxy.Proxy) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveProxy", ctx, proxy) + ret := m.ctrl.Call(m, "SaveProxy", ctx, arg1) ret0, _ := ret[0].(error) return ret0 } // SaveProxy indicates an expected call of SaveProxy. -func (mr *MockStoreMockRecorder) SaveProxy(ctx, proxy interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveProxy(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxy", reflect.TypeOf((*MockStore)(nil).SaveProxy), ctx, proxy) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxy", reflect.TypeOf((*MockStore)(nil).SaveProxy), ctx, arg1) } // SaveProxyAccessToken mocks base method. @@ -3621,23 +3627,23 @@ func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types3.Prox } // SaveProxyAccessToken indicates an expected call of SaveProxyAccessToken. -func (mr *MockStoreMockRecorder) SaveProxyAccessToken(ctx, token interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveProxyAccessToken(ctx, token any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxyAccessToken", reflect.TypeOf((*MockStore)(nil).SaveProxyAccessToken), ctx, token) } // SaveRoute mocks base method. -func (m *MockStore) SaveRoute(ctx context.Context, route *route.Route) error { +func (m *MockStore) SaveRoute(ctx context.Context, arg1 *route.Route) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveRoute", ctx, route) + ret := m.ctrl.Call(m, "SaveRoute", ctx, arg1) ret0, _ := ret[0].(error) return ret0 } // SaveRoute indicates an expected call of SaveRoute. -func (mr *MockStoreMockRecorder) SaveRoute(ctx, route interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveRoute(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockStore)(nil).SaveRoute), ctx, route) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockStore)(nil).SaveRoute), ctx, arg1) } // SaveSetupKey mocks base method. @@ -3649,7 +3655,7 @@ func (m *MockStore) SaveSetupKey(ctx context.Context, setupKey *types3.SetupKey) } // SaveSetupKey indicates an expected call of SaveSetupKey. -func (mr *MockStoreMockRecorder) SaveSetupKey(ctx, setupKey interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveSetupKey(ctx, setupKey any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSetupKey", reflect.TypeOf((*MockStore)(nil).SaveSetupKey), ctx, setupKey) } @@ -3663,7 +3669,7 @@ func (m *MockStore) SaveUser(ctx context.Context, user *types3.User) error { } // SaveUser indicates an expected call of SaveUser. -func (mr *MockStoreMockRecorder) SaveUser(ctx, user interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveUser(ctx, user any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUser", reflect.TypeOf((*MockStore)(nil).SaveUser), ctx, user) } @@ -3677,7 +3683,7 @@ func (m *MockStore) SaveUserInvite(ctx context.Context, invite *types3.UserInvit } // SaveUserInvite indicates an expected call of SaveUserInvite. -func (mr *MockStoreMockRecorder) SaveUserInvite(ctx, invite interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveUserInvite(ctx, invite any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUserInvite", reflect.TypeOf((*MockStore)(nil).SaveUserInvite), ctx, invite) } @@ -3691,7 +3697,7 @@ func (m *MockStore) SaveUserLastLogin(ctx context.Context, accountID, userID str } // SaveUserLastLogin indicates an expected call of SaveUserLastLogin. -func (mr *MockStoreMockRecorder) SaveUserLastLogin(ctx, accountID, userID, lastLogin interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveUserLastLogin(ctx, accountID, userID, lastLogin any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUserLastLogin", reflect.TypeOf((*MockStore)(nil).SaveUserLastLogin), ctx, accountID, userID, lastLogin) } @@ -3705,7 +3711,7 @@ func (m *MockStore) SaveUsers(ctx context.Context, users []*types3.User) error { } // SaveUsers indicates an expected call of SaveUsers. -func (mr *MockStoreMockRecorder) SaveUsers(ctx, users interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SaveUsers(ctx, users any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUsers", reflect.TypeOf((*MockStore)(nil).SaveUsers), ctx, users) } @@ -3717,23 +3723,23 @@ func (m *MockStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) { } // SetFieldEncrypt indicates an expected call of SetFieldEncrypt. -func (mr *MockStoreMockRecorder) SetFieldEncrypt(enc interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) SetFieldEncrypt(enc any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFieldEncrypt", reflect.TypeOf((*MockStore)(nil).SetFieldEncrypt), enc) } // UpdateAccountDomainAttributes mocks base method. -func (m *MockStore) UpdateAccountDomainAttributes(ctx context.Context, accountID, domain, category string, isPrimaryDomain bool) error { +func (m *MockStore) UpdateAccountDomainAttributes(ctx context.Context, accountID, arg2, category string, isPrimaryDomain bool) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAccountDomainAttributes", ctx, accountID, domain, category, isPrimaryDomain) + ret := m.ctrl.Call(m, "UpdateAccountDomainAttributes", ctx, accountID, arg2, category, isPrimaryDomain) ret0, _ := ret[0].(error) return ret0 } // UpdateAccountDomainAttributes indicates an expected call of UpdateAccountDomainAttributes. -func (mr *MockStoreMockRecorder) UpdateAccountDomainAttributes(ctx, accountID, domain, category, isPrimaryDomain interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateAccountDomainAttributes(ctx, accountID, arg2, category, isPrimaryDomain any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountDomainAttributes", reflect.TypeOf((*MockStore)(nil).UpdateAccountDomainAttributes), ctx, accountID, domain, category, isPrimaryDomain) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountDomainAttributes", reflect.TypeOf((*MockStore)(nil).UpdateAccountDomainAttributes), ctx, accountID, arg2, category, isPrimaryDomain) } // UpdateAccountNetwork mocks base method. @@ -3745,7 +3751,7 @@ func (m *MockStore) UpdateAccountNetwork(ctx context.Context, accountID string, } // UpdateAccountNetwork indicates an expected call of UpdateAccountNetwork. -func (mr *MockStoreMockRecorder) UpdateAccountNetwork(ctx, accountID, ipNet interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateAccountNetwork(ctx, accountID, ipNet any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetwork", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetwork), ctx, accountID, ipNet) } @@ -3759,7 +3765,7 @@ func (m *MockStore) UpdateAccountNetworkV6(ctx context.Context, accountID string } // UpdateAccountNetworkV6 indicates an expected call of UpdateAccountNetworkV6. -func (mr *MockStoreMockRecorder) UpdateAccountNetworkV6(ctx, accountID, ipNet interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateAccountNetworkV6(ctx, accountID, ipNet any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetworkV6", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetworkV6), ctx, accountID, ipNet) } @@ -3774,7 +3780,7 @@ func (m *MockStore) UpdateCustomDomain(ctx context.Context, accountID string, d } // UpdateCustomDomain indicates an expected call of UpdateCustomDomain. -func (mr *MockStoreMockRecorder) UpdateCustomDomain(ctx, accountID, d interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateCustomDomain(ctx, accountID, d any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCustomDomain", reflect.TypeOf((*MockStore)(nil).UpdateCustomDomain), ctx, accountID, d) } @@ -3788,7 +3794,7 @@ func (m *MockStore) UpdateDNSRecord(ctx context.Context, record *records.Record) } // UpdateDNSRecord indicates an expected call of UpdateDNSRecord. -func (mr *MockStoreMockRecorder) UpdateDNSRecord(ctx, record interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateDNSRecord(ctx, record any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDNSRecord", reflect.TypeOf((*MockStore)(nil).UpdateDNSRecord), ctx, record) } @@ -3802,7 +3808,7 @@ func (m *MockStore) UpdateGroup(ctx context.Context, group *types3.Group) error } // UpdateGroup indicates an expected call of UpdateGroup. -func (mr *MockStoreMockRecorder) UpdateGroup(ctx, group interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateGroup(ctx, group any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroup", reflect.TypeOf((*MockStore)(nil).UpdateGroup), ctx, group) } @@ -3816,7 +3822,7 @@ func (m *MockStore) UpdateGroups(ctx context.Context, accountID string, groups [ } // UpdateGroups indicates an expected call of UpdateGroups. -func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockStore)(nil).UpdateGroups), ctx, accountID, groups) } @@ -3830,7 +3836,7 @@ func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types1.Netw } // UpdateNetworkRouter indicates an expected call of UpdateNetworkRouter. -func (mr *MockStoreMockRecorder) UpdateNetworkRouter(ctx, router interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateNetworkRouter(ctx, router any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateNetworkRouter", reflect.TypeOf((*MockStore)(nil).UpdateNetworkRouter), ctx, router) } @@ -3844,23 +3850,23 @@ func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) er } // UpdateProxyHeartbeat indicates an expected call of UpdateProxyHeartbeat. -func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, p interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, p any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, p) } // UpdateService mocks base method. -func (m *MockStore) UpdateService(ctx context.Context, service *service.Service) error { +func (m *MockStore) UpdateService(ctx context.Context, arg1 *service.Service) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateService", ctx, service) + ret := m.ctrl.Call(m, "UpdateService", ctx, arg1) ret0, _ := ret[0].(error) return ret0 } // UpdateService indicates an expected call of UpdateService. -func (mr *MockStoreMockRecorder) UpdateService(ctx, service interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateService(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockStore)(nil).UpdateService), ctx, service) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockStore)(nil).UpdateService), ctx, arg1) } // UpdateZone mocks base method. @@ -3872,7 +3878,7 @@ func (m *MockStore) UpdateZone(ctx context.Context, zone *zones.Zone) error { } // UpdateZone indicates an expected call of UpdateZone. -func (mr *MockStoreMockRecorder) UpdateZone(ctx, zone interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateZone(ctx, zone any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateZone", reflect.TypeOf((*MockStore)(nil).UpdateZone), ctx, zone) } diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index 570de7631..fe3da7479 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" From 917ad880e355551c2953d1170118d6d919369d0b Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:07:51 +0900 Subject: [PATCH 22/36] [client] Rename TURN-specific wg proxy naming to relayed connections (#7231) --- client/iface/wgproxy/bind/proxy.go | 6 +-- client/iface/wgproxy/ebpf/proxy.go | 52 ++++++++++++------------- client/iface/wgproxy/ebpf/proxy_test.go | 22 +++++------ client/iface/wgproxy/ebpf/wrapper.go | 12 +++--- client/iface/wgproxy/proxy.go | 2 +- client/iface/wgproxy/proxy_test.go | 4 +- client/iface/wgproxy/redirect_test.go | 10 ++--- client/iface/wgproxy/udp/proxy.go | 4 +- client/internal/peer/conn.go | 7 ++-- client/internal/peer/worker_ice.go | 10 ++--- 10 files changed, 64 insertions(+), 65 deletions(-) diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go index be690ed4f..fcaee15c7 100644 --- a/client/iface/wgproxy/bind/proxy.go +++ b/client/iface/wgproxy/bind/proxy.go @@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind { return p } -// AddTurnConn adds a new connection to the bind. +// AddRelayedConn adds a new connection to the bind. // endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the // WireGuard configuration. // // Parameters: // - ctx: Context is used for proxyToLocal to avoid unnecessary error messages // - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address -// - remoteConn: The established TURN connection to the remote peer -func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { +// - remoteConn: The established relayed connection to the remote peer +func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { fakeNetIP, err := fakeAddress(nbAddr) if err != nil { return err diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go index 1b1a8ce1c..91c741c0d 100644 --- a/client/iface/wgproxy/ebpf/proxy.go +++ b/client/iface/wgproxy/ebpf/proxy.go @@ -30,9 +30,9 @@ type WGEBPFProxy struct { proxyPort int mtu uint16 - ebpfManager ebpfMgr.Manager - turnConnStore map[uint16]net.Conn - turnConnMutex sync.Mutex + ebpfManager ebpfMgr.Manager + relayedConnStore map[uint16]net.Conn + relayedConnMutex sync.Mutex lastUsedPort uint16 rawConnIPv4 net.PacketConn @@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy { localWGListenPort: wgPort, mtu: mtu, ebpfManager: ebpf.GetEbpfManagerInstance(), - turnConnStore: make(map[uint16]net.Conn), + relayedConnStore: make(map[uint16]net.Conn), } return wgProxy } @@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error { return nil } -// AddTurnConn add new turn connection for the proxy -func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) { - wgEndpointPort, err := p.storeTurnConn(turnConn) +// AddRelayedConn add new relayed connection for the proxy +func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) { + wgEndpointPort, err := p.storeRelayedConn(relayedConn) if err != nil { return nil, err } - log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort) + log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort) wgEndpoint := &net.UDPAddr{ IP: net.ParseIP(loopbackAddr), @@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error { return fmt.Errorf("failed to read UDP packet from WG: %w", err) } - p.turnConnMutex.Lock() - conn, ok := p.turnConnStore[uint16(addr.Port)] - p.turnConnMutex.Unlock() + p.relayedConnMutex.Lock() + conn, ok := p.relayedConnStore[uint16(addr.Port)] + p.relayedConnMutex.Unlock() if !ok { if p.ctx.Err() == nil { - log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port) + log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port) } return nil } if _, err := conn.Write(buf[:n]); err != nil { - return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err) + return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err) } return nil } -func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() np, err := p.nextFreePort() if err != nil { return np, err } - p.turnConnStore[np] = turnConn + p.relayedConnStore[np] = relayedConn return np, nil } -func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() - _, ok := p.turnConnStore[turnConnID] + _, ok := p.relayedConnStore[relayedConnID] if ok { - log.Debugf("remove turn conn from store by port: %d", turnConnID) + log.Debugf("remove relayed conn from store by port: %d", relayedConnID) } - delete(p.turnConnStore, turnConnID) + delete(p.relayedConnStore, relayedConnID) } func (p *WGEBPFProxy) nextFreePort() (uint16, error) { - if len(p.turnConnStore) == 65535 { - return 0, fmt.Errorf("reached maximum turn connection numbers") + if len(p.relayedConnStore) == 65535 { + return 0, fmt.Errorf("reached maximum relayed connection numbers") } generatePort: if p.lastUsedPort == 65535 { @@ -236,7 +236,7 @@ generatePort: p.lastUsedPort++ } - if _, ok := p.turnConnStore[p.lastUsedPort]; ok { + if _, ok := p.relayedConnStore[p.lastUsedPort]; ok { goto generatePort } return p.lastUsedPort, nil diff --git a/client/iface/wgproxy/ebpf/proxy_test.go b/client/iface/wgproxy/ebpf/proxy_test.go index 3ec4f0eba..228c06c9b 100644 --- a/client/iface/wgproxy/ebpf/proxy_test.go +++ b/client/iface/wgproxy/ebpf/proxy_test.go @@ -9,32 +9,32 @@ import ( func TestWGEBPFProxy_connStore(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) if p != 1 { t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort) } numOfConns := 10 for i := 0; i < numOfConns; i++ { - p, _ = wgProxy.storeTurnConn(nil) + p, _ = wgProxy.storeRelayedConn(nil) } if p != uint16(numOfConns)+1 { t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1) } - if len(wgProxy.turnConnStore) != numOfConns+1 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1) + if len(wgProxy.relayedConnStore) != numOfConns+1 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1) } } func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) wgProxy.lastUsedPort = 65535 - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) - if len(wgProxy.turnConnStore) != 2 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2) + if len(wgProxy.relayedConnStore) != 2 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2) } if p != 2 { @@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) for i := 0; i < 65535; i++ { - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) } - _, err := wgProxy.storeTurnConn(nil) + _, err := wgProxy.storeRelayedConn(nil) if err == nil { - t.Errorf("invalid turn conn store calculation") + t.Errorf("invalid relayed conn store calculation") } } diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go index a6156a661..f75e21aa6 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/ebpf/wrapper.go @@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper { } } -func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { - addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn) +func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { + addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn) if err != nil { - return fmt.Errorf("add turn conn: %w", err) + return fmt.Errorf("add relayed conn: %w", err) } headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr) @@ -252,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error { } func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { - defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port)) + defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port)) buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead) for { @@ -273,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { if ctx.Err() != nil { return } - log.Errorf("failed to write out turn pkg to local conn: %v", err) + log.Errorf("failed to write out relayed pkg to local conn: %v", err) } } } @@ -286,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err } p.closeListener.Notify() if !errors.Is(err, io.EOF) { - log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) + log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) } return 0, err } diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go index 40346bc15..b0033bffa 100644 --- a/client/iface/wgproxy/proxy.go +++ b/client/iface/wgproxy/proxy.go @@ -7,7 +7,7 @@ import ( // Proxy is a transfer layer between the relayed connection and the WireGuard type Proxy interface { - AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error + AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint Work() // Work start or resume the proxy Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works. diff --git a/client/iface/wgproxy/proxy_test.go b/client/iface/wgproxy/proxy_test.go index 1aeab66b7..d86cdbe80 100644 --- a/client/iface/wgproxy/proxy_test.go +++ b/client/iface/wgproxy/proxy_test.go @@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) { t.Run(tt.name, func(t *testing.T) { addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892") relayedConn := newMockConn() - err := tt.proxy.AddTurnConn(ctx, addr, relayedConn) + err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn) if err != nil { t.Errorf("error: %v", err) } @@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD _ = relayedServer.Close() }() - if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil { + if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil { t.Errorf("error: %v", err) } defer func() { diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go index 135970838..f0d59cc64 100644 --- a/client/iface/wgproxy/redirect_test.go +++ b/client/iface/wgproxy/redirect_test.go @@ -119,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } defer relayConn.Close() - // Add TURN connection to proxy - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + // Add relayed connection to proxy + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { @@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) { Port: 38746, } - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go index 783843aba..a0895c8c7 100644 --- a/client/iface/wgproxy/udp/proxy.go +++ b/client/iface/wgproxy/udp/proxy.go @@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy { return p } -// AddTurnConn +// AddRelayedConn dials the local WireGuard port and stores the relayed connection. // The provided Context must be non-nil. If the context expires before // the connection is complete, an error is returned. Once successfully // connected, any expiration of the context will not affect the // connection. -func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { +func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { dialer := net.Dialer{} localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort)) if err != nil { diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index a3c320027..b84b05671 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -445,7 +445,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.dumpState.NewLocalProxy() wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn) if err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) + conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err) return } ep = wgProxy.EndpointAddr() @@ -883,9 +883,8 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { } wgProxy := conn.config.WgConfig.WgInterface.GetProxy() - if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) - return nil, err + if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil { + return nil, fmt.Errorf("add relayed conn to proxy: %w", err) } return wgProxy, nil } diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index b1aa3e0f9..67f76f2e6 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -255,8 +255,8 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent return } - w.log.Debugf("turn agent dial") - remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer) + w.log.Debugf("agent dial") + remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer) if err != nil { w.log.Debugf("failed to dial the remote peer: %s", err) w.closeAgent(agent, w.agentDialerCancel) @@ -517,8 +517,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia w.logSuccessfulPaths(agent) return case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed: - // ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to - // notify the conn.onICEStateDisconnected changes to update the current used priority + // ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires + // notifying conn.onICEStateDisconnected so it can update the currently used priority. sessionChanged := w.closeAgent(agent, dialerCancel) @@ -532,7 +532,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia } } -func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { +func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { if isController(w.config) { return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd) } else { From e206f8827d284c0137cb5fff8c5e42534a1ddf1a Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga <17948409+lixmal@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:20:18 +0300 Subject: [PATCH 23/36] [management] Suppress staticcheck warnings for deprecated proto fields (#7261) --- .../controllers/network_map/controller/controller.go | 2 +- management/internals/shared/grpc/conversion.go | 2 +- management/internals/shared/grpc/server.go | 4 ++-- shared/management/client/client_test.go | 2 +- shared/management/networkmap/encode.go | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 356dc9f67..07f1938c5 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -1024,7 +1024,7 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI FirewallRules: []*proto.FirewallRule{}, FirewallRulesIsEmpty: true, DNSConfig: &proto.DNSConfig{ - ForwarderPort: dnsFwdPort, + ForwarderPort: dnsFwdPort, //nolint:staticcheck }, }, }, diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 74ceb3370..2b923836c 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -311,7 +311,7 @@ func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfi return &proto.JWTConfig{ Issuer: issuer, - Audience: audience, + Audience: audience, //nolint:staticcheck Audiences: audiences, KeysLocation: keysLocation, } diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 485f05a92..3d5f0a1b7 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -1140,7 +1140,7 @@ func (s *Server) GetDeviceAuthorizationFlow(ctx context.Context, req *proto.Encr Provider: proto.DeviceAuthorizationFlowProvider(provider), ProviderConfig: &proto.ProviderConfig{ ClientID: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientID, - ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret, + ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret, //nolint:staticcheck Domain: s.config.DeviceAuthorizationFlow.ProviderConfig.Domain, Audience: s.config.DeviceAuthorizationFlow.ProviderConfig.Audience, DeviceAuthEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint, @@ -1211,7 +1211,7 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp ProviderConfig: &proto.ProviderConfig{ Audience: s.config.PKCEAuthorizationFlow.ProviderConfig.Audience, ClientID: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientID, - ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret, + ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret, //nolint:staticcheck TokenEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint, AuthorizationEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint, Scope: s.config.PKCEAuthorizationFlow.ProviderConfig.Scope, diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index fe3da7479..d4888fee2 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -591,7 +591,7 @@ func Test_GetPKCEAuthorizationFlow(t *testing.T) { expectedFlowInfo := &mgmtProto.PKCEAuthorizationFlow{ ProviderConfig: &mgmtProto.ProviderConfig{ ClientID: "client", - ClientSecret: "secret", + ClientSecret: "secret", //nolint:staticcheck }, } diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go index ccde32faf..7e68861dc 100644 --- a/shared/management/networkmap/encode.go +++ b/shared/management/networkmap/encode.go @@ -247,7 +247,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort ServiceEnable: update.ServiceEnable, CustomZones: make([]*proto.CustomZone, 0, len(update.CustomZones)), NameServerGroups: make([]*proto.NameServerGroup, 0, len(update.NameServerGroups)), - ForwarderPort: forwardPort, + ForwarderPort: forwardPort, //nolint:staticcheck } for _, zone := range update.CustomZones { From e4b8bf39d28373178a215911e9fa12f54b5401a1 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:50:50 +0900 Subject: [PATCH 24/36] [client] Fix staticcheck findings from the updated golangci-lint (#7266) * Fix staticcheck findings reported by the updated golangci-lint * Skip the receive error log when the local context is done --- client/cmd/service_controller.go | 4 ++-- client/cmd/service_socket.go | 4 ++-- client/internal/acl/manager.go | 15 +++++++-------- client/internal/acl/manager_test.go | 8 ++++---- client/internal/dns/host_windows.go | 4 ++-- client/internal/dnsfwd/manager.go | 2 +- client/internal/engine.go | 2 +- client/internal/sleep/service.go | 4 ++-- client/internal/updater/manager.go | 2 +- client/server/panic_windows.go | 3 ++- client/ssh/server/command_execution.go | 4 ++-- flow/client/client.go | 9 ++++++--- sharedsock/example/main.go | 4 ++-- util/file.go | 6 +++--- 14 files changed, 37 insertions(+), 34 deletions(-) diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 9ba3bce25..b187a7b87 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -45,8 +45,8 @@ func daemonServerOptions(network string) []grpc.ServerOption { return nil } - creds := ipcauth.NewTransportCredentials() - if creds == nil { + creds := ipcauth.NewTransportCredentials() //nolint:staticcheck + if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS) return nil } diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go index ed1f001a7..bf3122f7c 100644 --- a/client/cmd/service_socket.go +++ b/client/cmd/service_socket.go @@ -27,8 +27,8 @@ func listenOnAddress(addr string) (*socketListener, error) { } if network == "npipe" { - listener, path, err := listenNamedPipe(address) - if err != nil { + listener, path, err := listenNamedPipe(address) //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on non-Windows builds return nil, err } return &socketListener{Listener: listener, network: network, address: path}, nil diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go index d9b179457..cbd9c5ab1 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -116,11 +116,11 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout // firewall state, so an identical hash means an identical resulting ruleset. func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) { return hashstructure.Hash(struct { - PeerRules []*mgmProto.FirewallRule - PeerRulesIsEmpty bool - RouteRules []*mgmProto.RouteFirewallRule - RouteRulesIsEmpty bool - DNSRouteFeatureFlag bool + PeerRules []*mgmProto.FirewallRule + PeerRulesIsEmpty bool + RouteRules []*mgmProto.RouteFirewallRule + RouteRulesIsEmpty bool + DNSRouteFeatureFlag bool }{ PeerRules: networkMap.GetFirewallRules(), PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(), @@ -144,13 +144,13 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers") rules = append(rules, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, }, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_OUT, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, @@ -407,7 +407,6 @@ func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) st return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo) } - // extractRuleIP extracts the peer IP from a firewall rule. // If sourcePrefixes is populated (new management), decode the first entry and use its address. // Otherwise fall back to the deprecated PeerIP string field (old management). diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 70ffefcce..8f737706e 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -5,9 +5,9 @@ import ( "net/netip" "testing" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/iface" @@ -87,7 +87,7 @@ func TestDefaultManager(t *testing.T) { networkMap.FirewallRules = append( networkMap.FirewallRules, &mgmProto.FirewallRule{ - PeerIP: "10.93.0.3", + PeerIP: "10.93.0.3", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_ICMP, @@ -556,12 +556,12 @@ func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) { func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap { nm := &mgmProto.NetworkMap{ - FirewallRulesIsEmpty: peerRules == 0, + FirewallRulesIsEmpty: peerRules == 0, RoutesFirewallRulesIsEmpty: routeRules == 0, } for i := range peerRules { nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{ - PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), + PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 2852dddb9..53380b2aa 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -459,7 +459,7 @@ func (r *registryConfigurator) flushDNSCache() { ret, _, err := dnsFlushResolverCacheFn.Call() if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { log.Errorf("DnsFlushResolverCache failed: %v", err) return } @@ -627,7 +627,7 @@ func refreshGroupPolicy() error { ) if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { return fmt.Errorf("RefreshPolicyEx failed: %w", err) } return fmt.Errorf("RefreshPolicyEx failed") diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index c4c16cd3f..29ca0d247 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -101,7 +101,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error { m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface) go func() { - if err := m.dnsForwarder.Listen(fwdEntries); err != nil { + if err := m.dnsForwarder.Listen(fwdEntries); err != nil { //nolint:staticcheck // todo handle close error if it is exists log.Errorf("failed to start DNS forwarder, err: %v", err) } diff --git a/client/internal/engine.go b/client/internal/engine.go index 5380651a5..7f3f8185f 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -2572,7 +2572,7 @@ func (e *Engine) SetCapture(pc device.PacketCapture) error { } afc := capture.NewAFPacketCapture(intf.Name(), sess) - if err := afc.Start(); err != nil { + if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err) } e.afpacketCapture = afc diff --git a/client/internal/sleep/service.go b/client/internal/sleep/service.go index 196a33f52..93691c4c7 100644 --- a/client/internal/sleep/service.go +++ b/client/internal/sleep/service.go @@ -18,8 +18,8 @@ type Service struct { } func New() (*Service, error) { - d, err := NewDetector() - if err != nil { + d, err := NewDetector() //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on platforms without a sleep detector return nil, err } diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go index 7fc300739..1b69368d0 100644 --- a/client/internal/updater/manager.go +++ b/client/internal/updater/manager.go @@ -435,7 +435,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error } inst := installer.New() - if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { + if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { //nolint:staticcheck // always errors on platforms without an installer log.Errorf("error triggering update: %v", err) m.statusRecorder.PublishEvent( cProto.SystemEvent_ERROR, diff --git a/client/server/panic_windows.go b/client/server/panic_windows.go index 8592f12ad..4bed6662f 100644 --- a/client/server/panic_windows.go +++ b/client/server/panic_windows.go @@ -3,6 +3,7 @@ package server import ( + "errors" "fmt" "os" "path" @@ -69,7 +70,7 @@ func setStdHandle(f *os.File) error { handle := f.Fd() r0, _, e1 := setStdHandleFn.Call(stdErrorHandle, handle) if r0 == 0 { - if e1 != nil { + if !errors.Is(e1, syscall.Errno(0)) { return e1 } return syscall.EINVAL diff --git a/client/ssh/server/command_execution.go b/client/ssh/server/command_execution.go index b0a85fe4b..c8b3240d0 100644 --- a/client/ssh/server/command_execution.go +++ b/client/ssh/server/command_execution.go @@ -75,8 +75,8 @@ func (s *Server) createCommand(logger *log.Entry, privilegeResult PrivilegeCheck } // Try su first for system integration (PAM/audit) when privileged - cmd, err := s.createSuCommand(logger, session, localUser, hasPty) - if err != nil || privilegeResult.UsedFallback { + cmd, err := s.createSuCommand(logger, session, localUser, hasPty) //nolint:staticcheck + if err != nil || privilegeResult.UsedFallback { //nolint:staticcheck // always errors on platforms without su logger.Debugf("su command failed, falling back to executor: %v", err) cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty) if err != nil { diff --git a/flow/client/client.go b/flow/client/client.go index 3f31c2464..fc07db833 100644 --- a/flow/client/client.go +++ b/flow/client/client.go @@ -146,11 +146,14 @@ func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHan streamStart := time.Now() - if err := c.receive(stream, msgHandler); err != nil { + // receive always returns a non-nil error once the stream breaks; + // handleRetryableError decides between reconnecting and exiting + // permanently on local context cancellation + err = c.receive(stream, msgHandler) + if !isContextDone(err) { log.Errorf("receive failed: %v", err) - return c.handleRetryableError(err, streamStart, backOff) } - return nil + return c.handleRetryableError(err, streamStart, backOff) } if err := backoff.Retry(operation, backOff); err != nil { diff --git a/sharedsock/example/main.go b/sharedsock/example/main.go index da62b276e..4fa1766b6 100644 --- a/sharedsock/example/main.go +++ b/sharedsock/example/main.go @@ -14,8 +14,8 @@ import ( func main() { port := 51820 - rawSock, err := sharedsock.Listen(port, sharedsock.NewIncomingSTUNFilter(), iface.DefaultMTU) - if err != nil { + rawSock, err := sharedsock.Listen(port, sharedsock.NewIncomingSTUNFilter(), iface.DefaultMTU) //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on non-Linux builds panic(err) } diff --git a/util/file.go b/util/file.go index 73ad05b18..926904f9f 100644 --- a/util/file.go +++ b/util/file.go @@ -26,7 +26,7 @@ func WriteBytesWithRestrictedPermission(ctx context.Context, file string, bs []b return fmt.Errorf("enforce permission: %w", err) } - return writeBytes(ctx, file, err, configDir, configFileName, bs) + return writeBytes(ctx, file, configDir, configFileName, bs) } // WriteJsonWithRestrictedPermission writes JSON config object to a file. Enforces permission on the parent directory @@ -106,10 +106,10 @@ func writeJson(ctx context.Context, file string, obj interface{}, configDir stri return fmt.Errorf("marshal: %w", err) } - return writeBytes(ctx, file, err, configDir, configFileName, bs) + return writeBytes(ctx, file, configDir, configFileName, bs) } -func writeBytes(ctx context.Context, file string, err error, configDir string, configFileName string, bs []byte) error { +func writeBytes(ctx context.Context, file string, configDir string, configFileName string, bs []byte) error { if ctx.Err() != nil { return fmt.Errorf("write bytes start: %w", ctx.Err()) } From 4a6efbb5fc043a8cd3fe5c6a8eea0473c26e9512 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 20 Aug 2026 18:32:04 +0300 Subject: [PATCH 25/36] [infrastructure] Skip store migration for Postgres deployments (#7207) --- infrastructure_files/migrate-to-enterprise.sh | 350 +++++++++++++++--- 1 file changed, 302 insertions(+), 48 deletions(-) diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index e2713c902..744ba5375 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -15,6 +15,12 @@ set -o pipefail # 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store. # 3. Traffic flow — add NATS + flow-enricher + flow-receiver. # +# Step 2 is skipped when the deployment already runs on Postgres +# (server.store.engine: postgres in config.yaml). Nothing is provisioned or +# migrated in that case and the store config is left exactly as the operator +# wrote it — the enterprise image reads the same Postgres the community image +# did. Such a deployment gets the image swap, and can still opt into step 3. +# # If any step fails once the stack has been touched, the script rolls itself # back automatically: generated files are removed, the Postgres volume this run # created is dropped, and the original deployment is started again. @@ -38,6 +44,18 @@ ENV_BACKUP="" PG_VOLUME_NAME="" BACKUP_DIR="" +# Store state. STORE_ENGINE is what the deployment runs on today; when it is +# already postgres, MIGRATE_POSTGRES stays "no" and nothing is provisioned. +# POSTGRES_SERVICE is empty when Postgres lives outside this compose project. +STORE_ENGINE="" +EXISTING_POSTGRES="no" +POSTGRES_DSN="" +POSTGRES_SERVICE="" +POSTGRES_DEPENDS_CONDITION="service_healthy" +# Whether this run needs to generate config.yaml.enterprise at all. A pure +# image swap does not. +ENTERPRISE_CONFIG="no" + NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" check_docker_compose() { @@ -192,6 +210,85 @@ detect_exposed_address() { yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST" } +# The engine is a config.yaml-only setting — there is no env override for it +# (combined/cmd/root.go reads it from YAML and derives the env vars), so +# config.yaml is authoritative. Absent means the sqlite default. +detect_store_engine() { + local engine + engine=$(yq eval '.server.store.engine // ""' "$CONFIG_YAML_HOST") + if [[ -z "$engine" ]] || [[ "$engine" == "null" ]]; then + engine="sqlite" + fi + echo "$engine" | tr '[:upper:]' '[:lower:]' +} + +detect_store_dsn() { + yq eval '.server.store.dsn // ""' "$CONFIG_YAML_HOST" +} + +# config.yaml is where a combined deployment carries its DSN; this only covers +# hand-rolled installs that keep it in the environment instead. +detect_store_dsn_from_compose() { + # `compose config` re-escapes a literal $ as $$ on the way out, so undo that + # to get the value the container actually receives. + $DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval " + .services[\"$COMBINED_SERVICE\"].environment.NB_STORE_ENGINE_POSTGRES_DSN // + .services[\"$COMBINED_SERVICE\"].environment.NETBIRD_STORE_ENGINE_POSTGRES_DSN // \"\" + " - 2>/dev/null | sed 's/\$\$/$/g' +} + +# Reads either DSN form: "host=db ..." or "postgres://user:pass@db:5432/name". +dsn_host() { + local dsn="$1" + case "$dsn" in + *://*) printf '%s' "$dsn" | sed -n 's,^[a-zA-Z+]*://\([^/?]*\).*,\1,p' | sed -e 's,.*@,,' -e 's,:.*,,' ;; + *) printf '%s' "$dsn" | sed -n 's/.*[[:space:]]*host=\([^[:space:]]*\).*/\1/p' ;; + esac +} + +# flow-enricher is its own container, so a loopback host or a socket path would +# reach the enricher rather than Postgres. Only flag hosts we can positively +# identify — an unparseable DSN must not leave the operator with no way forward. +dsn_host_reachable() { + local dsn="$1" + case "$(dsn_host "$dsn")" in + localhost | 127.* | ::1 | 0.0.0.0 | /*) return 1 ;; + *) return 0 ;; + esac +} + +# Names the compose service running this deployment's Postgres, for depends_on. +# Empty means external — the DSN host matched no service. A DSN with no readable +# host falls back to matching on image. +detect_postgres_service() { + local host + host=$(dsn_host "$POSTGRES_DSN") + if [[ -n "$host" ]]; then + if [[ "$(host="$host" yq eval '.services | has(env(host))' "$COMPOSE_FILE" 2>/dev/null)" == "true" ]]; then + echo "$host" + fi + return + fi + yq eval '.services | to_entries | map(select(.value.image // "" | test("(^|/)(postgres|postgis|pgvector|timescaledb)(:|@|$)"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +# depends_on: service_healthy is only legal if the service defines a healthcheck. +detect_postgres_depends_condition() { + local tag + tag=$(yq eval ".services[\"$POSTGRES_SERVICE\"].healthcheck | tag" "$COMPOSE_FILE" 2>/dev/null) + if [[ "$tag" == "!!map" ]]; then + echo "service_healthy" + else + echo "service_started" + fi +} + +env_value() { + local value="$1" + value=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/$$/g') + printf '"%s"' "$value" +} + detect_compose_network() { local tag tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null) @@ -228,16 +325,30 @@ services: NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL} EOF + # An existing Postgres is already wired up by the operator's own compose file, + # so only a Postgres this run creates needs a depends_on. if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then cat < "$ENTERPRISE_CONFIG_FILE" - yq eval " - .server.store.engine = \"postgres\" | - .server.store.dsn = \"$pg_dsn\" | - .server.activityStore.engine = \"postgres\" | - .server.activityStore.dsn = \"$pg_dsn\" | - .server.authStore.engine = \"postgres\" | - .server.authStore.dsn = \"$pg_dsn\" - " "$CONFIG_YAML_HOST" > "$ENTERPRISE_CONFIG_FILE" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + # Fresh Postgres: point every store section at it. migrate-store carries the + # SQLite contents across. + POSTGRES_DSN="$POSTGRES_DSN" yq eval -i ' + .server.store.engine = "postgres" | + .server.store.dsn = strenv(POSTGRES_DSN) | + .server.activityStore.engine = "postgres" | + .server.activityStore.dsn = strenv(POSTGRES_DSN) | + .server.authStore.engine = "postgres" | + .server.authStore.dsn = strenv(POSTGRES_DSN) + ' "$ENTERPRISE_CONFIG_FILE" + fi + # Otherwise the store config is the operator's and stays untouched. + # activityStore and authStore do not inherit from server.store — each falls + # back to its own SQLite file under dataDir — so repointing them at Postgres + # here would silently strand the existing audit log and the embedded IdP's + # users, with no migrate-store run to carry them over. if [[ "$ENABLE_FLOW" == "yes" ]]; then - local flow_addr="${NETBIRD_DOMAIN}" - yq eval -i " + NETBIRD_DOMAIN="$NETBIRD_DOMAIN" yq eval -i ' .server.trafficFlow.enabled = true | - .server.trafficFlow.address = \"$flow_addr\" | - .server.trafficFlow.interval = \"60s\" - " "$ENTERPRISE_CONFIG_FILE" + .server.trafficFlow.address = strenv(NETBIRD_DOMAIN) | + .server.trafficFlow.interval = "60s" + ' "$ENTERPRISE_CONFIG_FILE" fi } @@ -630,6 +761,91 @@ on_exit() { # Main # --------------------------------------------------------------------------- +# Already on Postgres: there is nothing to provision and nothing to migrate. +# The enterprise image reads the very same store config the community image +# did, so step 2 collapses to a no-op and the run is a plain image swap. +configure_existing_postgres() { + EXISTING_POSTGRES="yes" + MIGRATE_POSTGRES="no" + + # DSN first — detect_postgres_service prefers the host it names. + POSTGRES_DSN=$(detect_store_dsn) + if [[ -z "$POSTGRES_DSN" ]] || [[ "$POSTGRES_DSN" == "null" ]]; then + POSTGRES_DSN=$(detect_store_dsn_from_compose) + fi + if [[ "$POSTGRES_DSN" == "null" ]]; then + POSTGRES_DSN="" + fi + + POSTGRES_SERVICE=$(detect_postgres_service) + if [[ -n "$POSTGRES_SERVICE" ]]; then + POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition) + fi + + echo "Step 2: Postgres migration not needed — this deployment already runs on" + echo " Postgres. Its store configuration is reused as-is and left" + echo " untouched; no database is created and no data is moved." + if [[ -n "$POSTGRES_SERVICE" ]]; then + echo " Postgres service: $POSTGRES_SERVICE (in $COMPOSE_FILE)" + else + echo " Postgres service: managed outside $COMPOSE_FILE" + fi +} + +configure_sqlite_store() { + MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n") + [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0 + + # The override would otherwise merge into a service of the same name and + # quietly rewrite its image and credentials. + local existing + existing=$(yq eval '.services | has("postgres")' "$COMPOSE_FILE") + if [[ "$existing" == "true" ]]; then + echo "" > /dev/stderr + echo "$COMPOSE_FILE already defines a service named 'postgres', but config.yaml" > /dev/stderr + echo "still has server.store.engine: sqlite. This script would add its own" > /dev/stderr + echo "'postgres' service and Compose would merge the two." > /dev/stderr + echo "" > /dev/stderr + echo "Point server.store.engine at that Postgres yourself, or rename the service," > /dev/stderr + echo "then re-run." > /dev/stderr + exit 1 + fi + + echo "" + echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store" + echo " will be backed up automatically. To fully revert later, restore" + echo " that backup and delete docker-compose.override.yml +" + echo " config.yaml.enterprise." + local confirm + confirm=$(read_yes_no " Continue?" "y") + if [[ "$confirm" != "yes" ]]; then + MIGRATE_POSTGRES="no" + echo " Skipping Postgres migration." + return 0 + fi + + POSTGRES_PASSWORD=$(rand_password) + POSTGRES_SERVICE="postgres" + POSTGRES_DEPENDS_CONDITION="service_healthy" + POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable" +} + +# mysql, or something this script has never seen. Swapping the images is still +# valid; touching the store is not. +configure_unsupported_store() { + MIGRATE_POSTGRES="no" + echo " ⚠ server.store.engine is '$STORE_ENGINE'. This script only migrates" + echo " SQLite to Postgres, and traffic flow requires Postgres, so both are" + echo " unavailable here. The store configuration will be left untouched." + echo "" + local proceed + proceed=$(read_yes_no "Step 2 skipped. Continue with the image swap only?" "n") + if [[ "$proceed" != "yes" ]]; then + echo "Aborted." + exit 0 + fi +} + init_migration() { DOCKER_COMPOSE_COMMAND=$(check_docker_compose) check_yq @@ -679,12 +895,15 @@ init_migration() { exit 1 fi + STORE_ENGINE=$(detect_store_engine) + echo "Detected existing deployment:" echo " Combined service: $COMBINED_SERVICE" echo " Dashboard: $DASHBOARD_SERVICE" echo " config.yaml: $CONFIG_YAML_HOST" echo " Data volume: $DATA_VOLUME" echo " Network: $COMPOSE_NETWORK" + echo " Store engine: $STORE_ENGINE" echo "" require_eula_acceptance @@ -703,28 +922,17 @@ init_migration() { echo "Step 1: Image swap (community → Enterprise). License key required." NB_LICENSE_KEY=$(read_secret " License key") - # Step 2 — optional + # Step 2 — what this does depends on what the deployment already stores in. echo "" - MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n") - if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then - echo "" - echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store" - echo " will be backed up automatically. To fully revert later, restore" - echo " that backup and delete docker-compose.override.yml +" - echo " config.yaml.enterprise." - local confirm - confirm=$(read_yes_no " Continue?" "y") - if [[ "$confirm" != "yes" ]]; then - MIGRATE_POSTGRES="no" - echo " Skipping Postgres migration." - else - POSTGRES_PASSWORD=$(rand_password) - fi - fi + case "$STORE_ENGINE" in + postgres) configure_existing_postgres ;; + sqlite) configure_sqlite_store ;; + *) configure_unsupported_store ;; + esac # Step 3 — optional, only if Postgres is on (flow requires Postgres) echo "" - if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_POSTGRES" == "yes" ]]; then ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n") if [[ "$ENABLE_FLOW" == "yes" ]]; then # Auth secret MUST match server.authSecret from config.yaml @@ -748,12 +956,46 @@ init_migration() { echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr exit 1 fi + + # flow-enricher talks to Postgres directly, so this is the one place an + # existing deployment's DSN is actually needed — and the one place a host + # that only works from inside the server container shows up. + while :; do + local dsn_problem="" + if [[ -z "$POSTGRES_DSN" ]]; then + dsn_problem="No DSN could be read from $CONFIG_YAML_HOST or from the $COMBINED_SERVICE environment." + elif ! dsn_host_reachable "$POSTGRES_DSN"; then + dsn_problem="Its host '$(dsn_host "$POSTGRES_DSN")' only resolves inside the server container." + fi + [[ -n "$dsn_problem" ]] || break + + echo "" + echo " The flow enricher reaches Postgres from a container of its own." + echo " $dsn_problem" + echo " Enter a DSN reachable from other containers, or press Ctrl-C to abort." + POSTGRES_DSN=$(read_required " Postgres DSN (host=… user=… password=… dbname=… port=5432 sslmode=disable)") + done + + # Only where the operator owns Postgres: a DSN entered above may name a + # different host. The sqlite path creates its own service, nothing to find. + if [[ "$EXISTING_POSTGRES" == "yes" ]]; then + POSTGRES_SERVICE=$(detect_postgres_service) + if [[ -n "$POSTGRES_SERVICE" ]]; then + POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition) + fi + fi fi else ENABLE_FLOW="no" echo "Step 3 (traffic flow) skipped — requires Postgres." fi + # config.yaml.enterprise only exists to hold changes; without any there is + # nothing to generate and the server keeps running on its own config.yaml. + if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$ENABLE_FLOW" == "yes" ]]; then + ENTERPRISE_CONFIG="yes" + fi + check_data_directory check_stale_postgres_volume } @@ -771,7 +1013,7 @@ apply_changes() { sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak" fi - if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then echo "Writing $ENTERPRISE_CONFIG_FILE ..." install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE" render_enterprise_config @@ -807,6 +1049,9 @@ apply_changes() { echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" fi if [[ "$ENABLE_FLOW" == "yes" ]]; then + # Own variable name rather than NB_STORE_ENGINE_POSTGRES_DSN so that a + # deployment already setting that one keeps its own value. + echo "NB_ENTERPRISE_POSTGRES_DSN=$(env_value "$POSTGRES_DSN")" echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}" echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}" fi @@ -868,14 +1113,19 @@ print_summary() { echo " Summary" echo "──────────────────────────────────────────────────────────────────────" echo " Images: swapped to enterprise" - [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " Storage: Postgres (data migrated from SQLite)" - [[ "$MIGRATE_POSTGRES" != "yes" ]] && echo " Storage: SQLite (unchanged)" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo " Storage: Postgres (data migrated from SQLite)" + elif [[ "$EXISTING_POSTGRES" == "yes" ]]; then + echo " Storage: Postgres (pre-existing, configuration unchanged)" + else + echo " Storage: $STORE_ENGINE (unchanged)" + fi [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled" [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled" echo "" echo " Generated files (next to your docker-compose.yml):" echo " $OVERRIDE_FILE" - [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE" + [[ "$ENTERPRISE_CONFIG" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE" echo " .env (license key + secrets, mode 600)" [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)" [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)" @@ -899,7 +1149,11 @@ print_summary() { else echo " $DOCKER_COMPOSE_COMMAND down" fi - echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then + echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + else + echo " rm -f $OVERRIDE_FILE" + fi if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then echo " mv $ENV_BACKUP .env # restores .env as it was before this run" elif [[ "$ENV_EXISTED" == "no" ]]; then From 00243b28bc1f5fbc47b11ab18fd8983a9c3baca4 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:05:33 +0900 Subject: [PATCH 26/36] [client] Add missing anonymization and SSH privilege translations (#7269) --- client/ui/i18n/locales/de/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/es/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/fr/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/hu/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/it/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/ja/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/pt/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/ru/common.json | 23 ++++++++++++++++++++++- client/ui/i18n/locales/zh-CN/common.json | 23 ++++++++++++++++++++++- 9 files changed, 198 insertions(+), 9 deletions(-) diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index d02589591..1208a37fe 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -764,7 +764,19 @@ "message": "Sensible Informationen anonymisieren" }, "settings.troubleshooting.anonymize.help": { - "message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs." + "message": "Verbirgt IP-Adressen, Domains und andere sensible Werte." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Keine" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Standard" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strikt" }, "settings.troubleshooting.systemInfo.label": { "message": "Systeminformationen einschließen" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "Vorgang fehlgeschlagen." + }, + "settings.ssh.privilege.hint": { + "message": "Erfordert {actor}. Führen Sie stattdessen dies aus:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Sie können dies deaktivieren, aber zum erneuten Aktivieren sind {actor} erforderlich:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:" } } diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 3420b612b..6dc4ffd0b 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -764,7 +764,19 @@ "message": "Anonimizar información sensible" }, "settings.troubleshooting.anonymize.help": { - "message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros." + "message": "Oculta direcciones IP, dominios y otros valores sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Ninguno" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predeterminado" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estricto" }, "settings.troubleshooting.systemInfo.label": { "message": "Incluir información del sistema" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "La operación falló." + }, + "settings.ssh.privilege.hint": { + "message": "Requiere {actor}. Ejecute esto en su lugar:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:" } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index a83f85c12..d3e54440c 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -764,7 +764,19 @@ "message": "Anonymiser les informations sensibles" }, "settings.troubleshooting.anonymize.help": { - "message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux." + "message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Aucune" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Par défaut" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strict" }, "settings.troubleshooting.systemInfo.label": { "message": "Inclure les informations système" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "L’opération a échoué." + }, + "settings.ssh.privilege.hint": { + "message": "Nécessite {actor}. Exécutez plutôt ceci :" + }, + "settings.ssh.privilege.oneWay": { + "message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor} :" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor} :" } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b291f7a01..19aede17f 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -764,7 +764,19 @@ "message": "Érzékeny információk anonimizálása" }, "settings.troubleshooting.anonymize.help": { - "message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban." + "message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nincs" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Alapértelmezett" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Szigorú" }, "settings.troubleshooting.systemInfo.label": { "message": "Rendszerinformációk beillesztése" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "A művelet meghiúsult." + }, + "settings.ssh.privilege.hint": { + "message": "{actor} szükséges hozzá. Futtassa inkább ezt:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges:" } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index a68a8b32b..dab9e0cb4 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -764,7 +764,19 @@ "message": "Anonimizza informazioni sensibili" }, "settings.troubleshooting.anonymize.help": { - "message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log." + "message": "Nasconde indirizzi IP, domini e altri valori sensibili." + }, + "settings.troubleshooting.anonymize.info": { + "message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nessuna" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predefinito" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Rigoroso" }, "settings.troubleshooting.systemInfo.label": { "message": "Includi informazioni di sistema" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "Operazione non riuscita." + }, + "settings.ssh.privilege.hint": { + "message": "Richiede {actor}. Esegua invece questo:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:" } } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index ec69de9a5..246c232a8 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -764,7 +764,19 @@ "message": "機密情報を匿名化" }, "settings.troubleshooting.anonymize.help": { - "message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。" + "message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "なし" + }, + "settings.troubleshooting.anonymize.default": { + "message": "デフォルト" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "厳格" }, "settings.troubleshooting.systemInfo.label": { "message": "システム情報を含める" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "操作に失敗しました。" + }, + "settings.ssh.privilege.hint": { + "message": "{actor}が必要です。代わりに次のコマンドを実行してください:" + }, + "settings.ssh.privilege.oneWay": { + "message": "無効にはできますが、再度有効にするには{actor}が必要です:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "有効にはできますが、再度無効にするには{actor}が必要です:" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index ef1bfd372..418e93717 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -764,7 +764,19 @@ "message": "Anonimizar informações sensíveis" }, "settings.troubleshooting.anonymize.help": { - "message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs." + "message": "Oculta endereços IP, domínios e outros valores sensíveis." + }, + "settings.troubleshooting.anonymize.info": { + "message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nenhum" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Padrão" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estrito" }, "settings.troubleshooting.systemInfo.label": { "message": "Incluir informações do sistema" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "A operação falhou." + }, + "settings.ssh.privilege.hint": { + "message": "Requer {actor}. Execute isto em vez disso:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Você pode desativar isto, mas ativar novamente requer {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Você pode ativar isto, mas desativar novamente requer {actor}:" } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index a876387f4..958b5a21c 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -764,7 +764,19 @@ "message": "Анонимизировать конфиденциальную информацию" }, "settings.troubleshooting.anonymize.help": { - "message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах." + "message": "Скрывает IP-адреса, домены и другие конфиденциальные значения." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Нет" + }, + "settings.troubleshooting.anonymize.default": { + "message": "По умолчанию" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Строгий" }, "settings.troubleshooting.systemInfo.label": { "message": "Включить сведения о системе" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "Не удалось выполнить операцию." + }, + "settings.ssh.privilege.hint": { + "message": "Требуются {actor}. Выполните вместо этого:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Отключить можно, но чтобы включить снова, нужны {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Включить можно, но чтобы отключить снова, нужны {actor}:" } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 542b2b045..90ae5e003 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -764,7 +764,19 @@ "message": "匿名化敏感信息" }, "settings.troubleshooting.anonymize.help": { - "message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。" + "message": "隐藏 IP 地址、域名和其他敏感值。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "无" + }, + "settings.troubleshooting.anonymize.default": { + "message": "默认" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "严格" }, "settings.troubleshooting.systemInfo.label": { "message": "包含系统信息" @@ -1338,5 +1350,14 @@ }, "error.unknown": { "message": "操作失败。" + }, + "settings.ssh.privilege.hint": { + "message": "需要{actor}。请改为运行:" + }, + "settings.ssh.privilege.oneWay": { + "message": "您可以关闭此项,但重新开启需要{actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "您可以开启此项,但再次关闭需要{actor}:" } } From 79a06720b684768b421f0a54f3bb14f22704994f Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:55:52 +0900 Subject: [PATCH 27/36] [client] Add a lazy-connection override and device name reporting to the WASM client (#7276) --- client/embed/embed.go | 16 +++++++++ client/system/info_js.go | 9 ++++- client/system/info_js_test.go | 27 +++++++++++++++ client/system/process_test.go | 2 ++ client/wasm/cmd/main.go | 35 ++++++++++++++++--- client/wasm/cmd/main_test.go | 64 +++++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 client/system/info_js_test.go create mode 100644 client/wasm/cmd/main_test.go diff --git a/client/embed/embed.go b/client/embed/embed.go index 1b2d84d7e..079e03c63 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -91,6 +91,13 @@ type Options struct { // when the embedded client must never act as a stepping stone into // the host's local network (e.g. the proxy's overlay peer). BlockLANAccess bool + // LazyConnectionEnabled is a tri-state local override for lazy connections, + // mirroring the NB_LAZY_CONN env var. Nil defers to the management feature + // flag; a set value overrides it in both directions. A short-lived client + // that reaches only a few known peers can set this to false, so its peers + // connect eagerly and the first request does not wait for the connection to + // be established. + LazyConnectionEnabled *bool // WireguardPort is the port for the tunnel interface. Use 0 for a random port. WireguardPort *int // MTU is the MTU for the tunnel interface. @@ -220,6 +227,15 @@ func New(opts Options) (*Client, error) { config.PrivateKey = opts.PrivateKey } + if opts.LazyConnectionEnabled != nil { + // Runtime-only override, read back through lazyconn.ParseState; a set value + // wins over the management feature flag in both directions. + config.LazyConnection = "off" + if *opts.LazyConnectionEnabled { + config.LazyConnection = "on" + } + } + if opts.Performance.PreallocatedBuffersPerPool != nil { wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool) } diff --git a/client/system/info_js.go b/client/system/info_js.go index f32532881..3323fb542 100644 --- a/client/system/info_js.go +++ b/client/system/info_js.go @@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() { } // GetInfo retrieves system information for WASM environment -func GetInfo(_ context.Context) *Info { +func GetInfo(ctx context.Context) *Info { info := &Info{ GoOS: runtime.GOOS, Kernel: runtime.GOARCH, @@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info { collectBrowserInfo(info) collectLocationInfo(info) collectSystemInfo(info) + + // A caller-provided device name wins, as on the other platforms. A peer + // registered over an API keeps reporting the name it was registered with, + // so its meta does not change on the first sync. + if name := extractDeviceName(ctx, info.Hostname); name != "" { + info.Hostname = name + } return info } diff --git a/client/system/info_js_test.go b/client/system/info_js_test.go new file mode 100644 index 000000000..e2a33ada0 --- /dev/null +++ b/client/system/info_js_test.go @@ -0,0 +1,27 @@ +//go:build js + +package system + +import ( + "context" + "testing" +) + +// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the +// reported hostname, so a peer registered over an API keeps reporting the name +// it was registered with instead of renaming itself on its first sync. +func TestGetInfoHonorsDeviceName(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name") + if got := GetInfo(ctx).Hostname; got != "session-name" { + t.Errorf("hostname should carry the caller's device name, got %q", got) + } +} + +// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of +// always setting the context value: an empty name must not blank the hostname. +func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "") + if got := GetInfo(ctx).Hostname; got == "" { + t.Error("an empty device name must not blank the hostname") + } +} diff --git a/client/system/process_test.go b/client/system/process_test.go index 9d0a6b935..de1cfc1db 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -1,3 +1,5 @@ +//go:build windows || (linux && !android) || (darwin && !ios) || freebsd + package system import ( diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 4683f4033..260a528f0 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -56,8 +56,7 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error { // parseClientOptions extracts NetBird options from JavaScript object func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options := netbird.Options{ - DeviceName: "dashboard-client", - LogLevel: defaultLogLevel, + LogLevel: defaultLogLevel, } if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() { @@ -87,13 +86,41 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options.DeviceName = deviceName.String() } - if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() { - options.DisableIPv6 = disableIPv6.Bool() + disableIPv6, err := boolOption(jsOptions, "disableIPv6") + if err != nil { + return options, err + } + if disableIPv6 != nil { + options.DisableIPv6 = *disableIPv6 } + // The caller decides whether this client uses lazy connections; left unset it + // defers to the management feature flag. A short-lived, interactive caller + // turns it off so its sessions reach the few peers their grant covers eagerly, + // instead of the first request waiting for the connection to be established. + lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled") + if err != nil { + return options, err + } + options.LazyConnectionEnabled = lazyConnectionEnabled + return options, nil } +// boolOption reads a boolean option, returning nil when the caller left it out. +// js.Value.Bool panics on any other type, so a wrong type is reported instead. +func boolOption(jsOptions js.Value, name string) (*bool, error) { + v := jsOptions.Get(name) + if v.IsNull() || v.IsUndefined() { + return nil, nil + } + if v.Type() != js.TypeBoolean { + return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type()) + } + b := v.Bool() + return &b, nil +} + // createStartMethod creates the start method for the client func createStartMethod(client *netbird.Client) js.Func { return js.FuncOf(func(this js.Value, args []js.Value) any { diff --git a/client/wasm/cmd/main_test.go b/client/wasm/cmd/main_test.go new file mode 100644 index 000000000..3ec5a8f6a --- /dev/null +++ b/client/wasm/cmd/main_test.go @@ -0,0 +1,64 @@ +//go:build js + +package main + +import ( + "syscall/js" + "testing" +) + +// TestParseClientOptionsBooleans covers the boolean options against the value +// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean, +// so a wrong type has to be rejected before it reaches the client. +func TestParseClientOptionsBooleans(t *testing.T) { + t.Run("unset leaves the lazy override empty", func(t *testing.T) { + options, err := parseClientOptions(js.Global().Get("Object").New()) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + if options.DisableIPv6 { + t.Error("disableIPv6 should default to false") + } + }) + + t.Run("null defers to the management flag", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", js.Null()) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + }) + + t.Run("booleans are carried through", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", false) + jsOptions.Set("disableIPv6", true) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled { + t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled) + } + if !options.DisableIPv6 { + t.Error("disableIPv6 should be true") + } + }) + + t.Run("a non-boolean is rejected", func(t *testing.T) { + for _, value := range []any{"true", 1, js.Global().Get("Object").New()} { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", value) + if _, err := parseClientOptions(jsOptions); err == nil { + t.Errorf("value %v should be rejected", value) + } + } + }) +} From 335adfe9c371cdd3b7433d62ec9ac888a57aa2c1 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:15:10 +0900 Subject: [PATCH 28/36] [client] Move the PCP implementation to the go-nat fork (#7282) --- .github/workflows/no-new-replace.yml | 78 ++++ client/internal/peer/worker_ice.go | 11 + client/internal/portforward/manager.go | 30 +- client/internal/portforward/pcp/client.go | 408 ------------------ .../internal/portforward/pcp/client_test.go | 187 -------- client/internal/portforward/pcp/nat.go | 222 ---------- client/internal/portforward/pcp/protocol.go | 225 ---------- client/internal/portforward/pinhole_test.go | 116 +++++ client/internal/portforward/state.go | 89 +++- client/internal/portforward/state_test.go | 140 ++++++ go.mod | 2 +- go.sum | 4 +- 12 files changed, 451 insertions(+), 1061 deletions(-) create mode 100644 .github/workflows/no-new-replace.yml delete mode 100644 client/internal/portforward/pcp/client.go delete mode 100644 client/internal/portforward/pcp/client_test.go delete mode 100644 client/internal/portforward/pcp/nat.go delete mode 100644 client/internal/portforward/pcp/protocol.go create mode 100644 client/internal/portforward/pinhole_test.go create mode 100644 client/internal/portforward/state_test.go diff --git a/.github/workflows/no-new-replace.yml b/.github/workflows/no-new-replace.yml new file mode 100644 index 000000000..b906ce450 --- /dev/null +++ b/.github/workflows/no-new-replace.yml @@ -0,0 +1,78 @@ +name: No New Replace Directives + +on: + pull_request: + paths: + - "go.mod" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-replace-directives: + name: check-replace-directives + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + + - name: Compare replace directives against the base branch + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + # A replace directive only applies when this module is the main + # module. Anything importing netbird as a library, the embedded + # clients among them, resolves the replaced path upstream instead and + # fails to build against whatever the replacement provides. Requiring + # a fork under its own module path avoids that; a replace does not. + # + # go.mod is parsed rather than diffed so that reordering, comments and + # single-line versus block syntax do not register as changes. + # + # Versions are part of the key because a replace can be scoped to one + # version of a module. Keyed on paths alone, retargeting such a + # directive at a different version would read as unchanged. + list_replaces() { + go mod edit -json "$1" \ + | jq -r ' + def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end); + (.Replace // [])[] | "\(.Old | ref) => \(.New | ref)" + ' \ + | sort + } + + git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod + list_replaces /tmp/base-go.mod > /tmp/base-replaces + list_replaces go.mod > /tmp/head-replaces + + added=$(comm -13 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$added" ]; then + echo "::error::This PR adds a replace directive to go.mod:" + echo "$added" | sed 's/^/ /' + echo "" + echo "A replace directive applies only to the main module, so it does not" + echo "reach anything that imports netbird as a library. Require the module" + echo "under a path you control instead, as done for github.com/netbirdio/go-nat." + exit 1 + fi + + removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$removed" ]; then + echo "This PR removes replace directives:" + echo "$removed" | sed 's/^/ /' + fi + echo "No new replace directives." diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 67f76f2e6..83cac13f5 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -389,6 +389,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) { return } + // A forwarded candidate only makes sense for an IPv4 mapping, which + // translates a port on the gateway's address. An IPv6 pinhole translates + // nothing: it unblocks the address ICE already gathers as a host candidate, + // so there is no second address to advertise. Injecting one here would also + // paste an IPv6 address onto whichever server-reflexive candidate arrived + // first, which is usually IPv4. + if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil { + w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType) + return + } + w.muxAgent.Lock() if w.portForwardAttempted { w.muxAgent.Unlock() diff --git a/client/internal/portforward/manager.go b/client/internal/portforward/manager.go index b0680160c..7d5a4cb9e 100644 --- a/client/internal/portforward/manager.go +++ b/client/internal/portforward/manager.go @@ -10,10 +10,8 @@ import ( "sync" "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) const ( @@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) { if err != nil { return nil, nil, fmt.Errorf("create port mapping: %w", err) } + + // Only meaningful once a mapping has been attempted: that is what opens the + // pinhole and records its outcome. + logIPv6Pinhole(gateway) + return gateway, mapping, nil } @@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b return false } - pcpNAT, ok := gateway.(*pcp.NAT) + // Assert on the interface, not on a concrete type: a dual-stack gateway is + // a wrapper around the IPv4 NAT, so a type assertion misses it. + checker, ok := gateway.(nat.HealthChecker) if !ok { return false } @@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx) + epoch, serverRestarted, err := checker.CheckServerHealth(ctx) if err != nil { log.Debugf("PCP health check failed: %v", err) return false @@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) { func isPermanentLeaseRequired(err error) bool { return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error()) } + +// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best +// effort and never fail a mapping on their own, so this is the only way to see +// whether one was actually opened. +func logIPv6Pinhole(gateway nat.NAT) { + reporter, ok := gateway.(nat.IPv6PinholeReporter) + if !ok { + return + } + if err := reporter.IPv6PinholeError(); err != nil { + log.Warnf("IPv6 pinhole: %v", err) + return + } + log.Infof("IPv6 pinhole open") +} diff --git a/client/internal/portforward/pcp/client.go b/client/internal/portforward/pcp/client.go deleted file mode 100644 index f6d243ef9..000000000 --- a/client/internal/portforward/pcp/client.go +++ /dev/null @@ -1,408 +0,0 @@ -package pcp - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "net" - "net/netip" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -const ( - defaultTimeout = 3 * time.Second - responseBufferSize = 128 - - // RFC 6887 Section 8.1.1 retry timing - initialRetryDelay = 3 * time.Second - maxRetryDelay = 1024 * time.Second - maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case -) - -// Client is a PCP protocol client. -// All methods are safe for concurrent use. -type Client struct { - gateway netip.Addr - timeout time.Duration - - mu sync.Mutex - // localIP caches the resolved local IP address. - localIP netip.Addr - // lastEpoch is the last observed server epoch value. - lastEpoch uint32 - // epochTime tracks when lastEpoch was received for state loss detection. - epochTime time.Time - // externalIP caches the external IP from the last successful MAP response. - externalIP netip.Addr - // epochStateLost is set when epoch indicates server restart. - epochStateLost bool -} - -// NewClient creates a new PCP client for the gateway at the given IP. -func NewClient(gateway net.IP) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: defaultTimeout, - } -} - -// NewClientWithTimeout creates a new PCP client with a custom timeout. -func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: timeout, - } -} - -// SetLocalIP sets the local IP address to use in PCP requests. -func (c *Client) SetLocalIP(ip net.IP) { - addr, ok := netip.AddrFromSlice(ip) - if !ok { - log.Debugf("invalid local IP: %v", ip) - } - c.mu.Lock() - c.localIP = addr.Unmap() - c.mu.Unlock() -} - -// Gateway returns the gateway IP address. -func (c *Client) Gateway() net.IP { - return c.gateway.AsSlice() -} - -// Announce sends a PCP ANNOUNCE request to discover PCP support. -// Returns the server's epoch time on success. -func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) { - localIP, err := c.getLocalIP() - if err != nil { - return 0, fmt.Errorf("get local IP: %w", err) - } - - req := buildAnnounceRequest(localIP) - resp, err := c.sendRequest(ctx, req) - if err != nil { - return 0, fmt.Errorf("send announce: %w", err) - } - - parsed, err := parseResponse(resp) - if err != nil { - return 0, fmt.Errorf("parse announce response: %w", err) - } - - if parsed.ResultCode != ResultSuccess { - return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode)) - } - - c.mu.Lock() - if c.updateEpochLocked(parsed.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.mu.Unlock() - return parsed.Epoch, nil -} - -// AddPortMapping requests a port mapping from the PCP server. -func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) { - return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime) -} - -// AddPortMappingWithHint requests a port mapping with suggested external port and IP. -// Use lifetime <= 0 to delete a mapping. -func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) { - var extIP netip.Addr - if suggestedExtIP != nil { - var ok bool - extIP, ok = netip.AddrFromSlice(suggestedExtIP) - if !ok { - log.Debugf("invalid suggested external IP: %v", suggestedExtIP) - } - extIP = extIP.Unmap() - } - return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime) -} - -func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) { - localIP, err := c.getLocalIP() - if err != nil { - return nil, fmt.Errorf("get local IP: %w", err) - } - - proto, err := protocolNumber(protocol) - if err != nil { - return nil, fmt.Errorf("parse protocol: %w", err) - } - - var nonce [12]byte - if _, err := rand.Read(nonce[:]); err != nil { - return nil, fmt.Errorf("generate nonce: %w", err) - } - - // Convert lifetime to seconds. Lifetime 0 means delete, so only apply - // default for positive durations that round to 0 seconds. - var lifetimeSec uint32 - if lifetime > 0 { - lifetimeSec = uint32(lifetime.Seconds()) - if lifetimeSec == 0 { - lifetimeSec = DefaultLifetime - } - } - - req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec) - - resp, err := c.sendRequest(ctx, req) - if err != nil { - return nil, fmt.Errorf("send map request: %w", err) - } - - mapResp, err := parseMapResponse(resp) - if err != nil { - return nil, fmt.Errorf("parse map response: %w", err) - } - - if mapResp.Nonce != nonce { - return nil, fmt.Errorf("nonce mismatch in response") - } - - if mapResp.Protocol != proto { - return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol) - } - if mapResp.InternalPort != uint16(internalPort) { - return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort) - } - - if mapResp.ResultCode != ResultSuccess { - return nil, &Error{ - Code: mapResp.ResultCode, - Message: ResultCodeString(mapResp.ResultCode), - } - } - - c.mu.Lock() - if c.updateEpochLocked(mapResp.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.cacheExternalIPLocked(mapResp.ExternalIP) - c.mu.Unlock() - return mapResp, nil -} - -// DeletePortMapping removes a port mapping by requesting zero lifetime. -func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil { - var pcpErr *Error - if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized { - return nil - } - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// GetExternalAddress returns the external IP address. -// First checks for a cached value from previous MAP responses. -// If not cached, creates a short-lived mapping to discover the external IP. -func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) { - c.mu.Lock() - if c.externalIP.IsValid() { - ip := c.externalIP.AsSlice() - c.mu.Unlock() - return ip, nil - } - c.mu.Unlock() - - // Use an ephemeral port in the dynamic range (49152-65535). - // Port 0 is not valid with UDP/TCP protocols per RFC 6887. - ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152) - - // Use minimal lifetime (1 second) for discovery. - resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second) - if err != nil { - return nil, fmt.Errorf("create temporary mapping: %w", err) - } - - if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil { - log.Debugf("cleanup temporary PCP mapping: %v", err) - } - - return resp.ExternalIP.AsSlice(), nil -} - -// LastEpoch returns the last observed server epoch value. -// A decrease in epoch indicates the server may have restarted and mappings may be lost. -func (c *Client) LastEpoch() uint32 { - c.mu.Lock() - defer c.mu.Unlock() - return c.lastEpoch -} - -// EpochStateLost returns true if epoch state loss was detected and clears the flag. -func (c *Client) EpochStateLost() bool { - c.mu.Lock() - defer c.mu.Unlock() - lost := c.epochStateLost - c.epochStateLost = false - return lost -} - -// updateEpoch updates the epoch tracking and detects potential state loss. -// Returns true if state loss was detected (server likely restarted). -// Caller must hold c.mu. -func (c *Client) updateEpochLocked(newEpoch uint32) bool { - now := time.Now() - stateLost := false - - // RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss. - // client_delta = time since last response - // server_delta = epoch change since last response - // Invalid if: client_delta+2 < server_delta - server_delta/16 - // OR: server_delta+2 < client_delta - client_delta/16 - // The +2 handles quantization, /16 (6.25%) handles clock drift. - if !c.epochTime.IsZero() && c.lastEpoch > 0 { - clientDelta := uint32(now.Sub(c.epochTime).Seconds()) - serverDelta := newEpoch - c.lastEpoch - - // Check for epoch going backwards or jumping unexpectedly. - // Subtraction is safe: serverDelta/16 is always <= serverDelta. - if clientDelta+2 < serverDelta-(serverDelta/16) || - serverDelta+2 < clientDelta-(clientDelta/16) { - stateLost = true - c.epochStateLost = true - } - } - - c.lastEpoch = newEpoch - c.epochTime = now - return stateLost -} - -// cacheExternalIP stores the external IP from a successful MAP response. -// Caller must hold c.mu. -func (c *Client) cacheExternalIPLocked(ip netip.Addr) { - if ip.IsValid() && !ip.IsUnspecified() { - c.externalIP = ip - } -} - -// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1. -func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) { - addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port} - - var lastErr error - delay := initialRetryDelay - - for range maxRetries { - resp, err := c.sendOnce(ctx, addr, req) - if err == nil { - return resp, nil - } - lastErr = err - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - // RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT) - // RAND is random between -0.1 and +0.1 - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(retryDelayWithJitter(delay)): - } - delay = min(delay*2, maxRetryDelay) - } - - return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr) -} - -// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1]. -func retryDelayWithJitter(d time.Duration) time.Duration { - var b [1]byte - _, _ = rand.Read(b[:]) - // Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1 - jitter := (float64(b[0])/255.0)*0.2 - 0.1 - return time.Duration(float64(d) * (1 + jitter)) -} - -func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) { - // Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3. - conn, err := net.ListenUDP("udp", nil) - if err != nil { - return nil, fmt.Errorf("listen: %w", err) - } - defer func() { - if err := conn.Close(); err != nil { - log.Debugf("close UDP connection: %v", err) - } - }() - - timeout := c.timeout - if deadline, ok := ctx.Deadline(); ok { - if remaining := time.Until(deadline); remaining < timeout { - timeout = remaining - } - } - - if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { - return nil, fmt.Errorf("set deadline: %w", err) - } - - if _, err := conn.WriteToUDP(req, addr); err != nil { - return nil, fmt.Errorf("write: %w", err) - } - - resp := make([]byte, responseBufferSize) - n, from, err := conn.ReadFromUDP(resp) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - - // RFC 6887 §8.3: Validate response came from expected PCP server. - if !from.IP.Equal(addr.IP) { - return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP) - } - - return resp[:n], nil -} - -func (c *Client) getLocalIP() (netip.Addr, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.localIP.IsValid() { - return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway) - } - return c.localIP, nil -} - -func protocolNumber(protocol string) (uint8, error) { - switch protocol { - case "udp", "UDP": - return ProtoUDP, nil - case "tcp", "TCP": - return ProtoTCP, nil - default: - return 0, fmt.Errorf("unsupported protocol: %s", protocol) - } -} - -// Error represents a PCP error response. -type Error struct { - Code uint8 - Message string -} - -func (e *Error) Error() string { - return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code) -} diff --git a/client/internal/portforward/pcp/client_test.go b/client/internal/portforward/pcp/client_test.go deleted file mode 100644 index 79f44a426..000000000 --- a/client/internal/portforward/pcp/client_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package pcp - -import ( - "context" - "net" - "net/netip" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddrConversion(t *testing.T) { - tests := []struct { - name string - addr netip.Addr - }{ - {"IPv4", netip.MustParseAddr("192.168.1.100")}, - {"IPv4 loopback", netip.MustParseAddr("127.0.0.1")}, - {"IPv6", netip.MustParseAddr("2001:db8::1")}, - {"IPv6 loopback", netip.MustParseAddr("::1")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b16 := addrTo16(tt.addr) - - recovered := addrFrom16(b16) - assert.Equal(t, tt.addr, recovered, "address should round-trip") - }) - } -} - -func TestBuildAnnounceRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - req := buildAnnounceRequest(clientIP) - - require.Len(t, req, headerSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpAnnounce), req[1], "opcode") - - // Check client IP is properly encoded as IPv4-mapped IPv6 - assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10") - assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11") - assert.Equal(t, byte(192), req[20], "IP octet 1") - assert.Equal(t, byte(168), req[21], "IP octet 2") - assert.Equal(t, byte(1), req[22], "IP octet 3") - assert.Equal(t, byte(100), req[23], "IP octet 4") -} - -func TestBuildMapRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} - req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600) - - require.Len(t, req, mapRequestSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpMap), req[1], "opcode") - - // Lifetime at bytes 4-7 - assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime") - - // Nonce at bytes 24-35 - assert.Equal(t, nonce[:], req[24:36], "nonce") - - // Protocol at byte 36 - assert.Equal(t, byte(ProtoUDP), req[36], "protocol") - - // Internal port at bytes 40-41 - assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port") - - // External port at bytes 42-43 - assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port") -} - -func TestParseResponse(t *testing.T) { - // Construct a valid ANNOUNCE response - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce | OpReply - // Result code = 0 (success) - // Lifetime = 0 - // Epoch = 12345 - resp[8] = 0 - resp[9] = 0 - resp[10] = 0x30 - resp[11] = 0x39 - - parsed, err := parseResponse(resp) - require.NoError(t, err) - assert.Equal(t, uint8(Version), parsed.Version) - assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode) - assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode) - assert.Equal(t, uint32(12345), parsed.Epoch) -} - -func TestParseResponseErrors(t *testing.T) { - t.Run("too short", func(t *testing.T) { - _, err := parseResponse([]byte{1, 2, 3}) - assert.Error(t, err) - }) - - t.Run("wrong version", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = 1 // Wrong version - resp[1] = OpReply - _, err := parseResponse(resp) - assert.Error(t, err) - }) - - t.Run("missing reply bit", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce // Missing OpReply bit - _, err := parseResponse(resp) - assert.Error(t, err) - }) -} - -func TestResultCodeString(t *testing.T) { - assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess)) - assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized)) - assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch)) - assert.Contains(t, ResultCodeString(255), "UNKNOWN") -} - -func TestProtocolNumber(t *testing.T) { - proto, err := protocolNumber("udp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - proto, err = protocolNumber("tcp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoTCP), proto) - - proto, err = protocolNumber("UDP") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - _, err = protocolNumber("icmp") - assert.Error(t, err) -} - -func TestClientCreation(t *testing.T) { - gateway := netip.MustParseAddr("192.168.1.1").AsSlice() - - client := NewClient(gateway) - assert.Equal(t, net.IP(gateway), client.Gateway()) - assert.Equal(t, defaultTimeout, client.timeout) - - clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second) - assert.Equal(t, 5*time.Second, clientWithTimeout.timeout) -} - -func TestNATType(t *testing.T) { - n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice()) - assert.Equal(t, "PCP", n.Type()) -} - -// Integration test - skipped unless PCP_TEST_GATEWAY env is set -func TestClientIntegration(t *testing.T) { - t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=") - - gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway - localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP - - client := NewClient(gateway) - client.SetLocalIP(localIP) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Test ANNOUNCE - epoch, err := client.Announce(ctx) - require.NoError(t, err) - t.Logf("Server epoch: %d", epoch) - - // Test MAP - resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour) - require.NoError(t, err) - t.Logf("Mapping: internal=%d external=%d externalIP=%s", - resp.InternalPort, resp.ExternalPort, resp.ExternalIP) - - // Cleanup - err = client.DeletePortMapping(ctx, "udp", 51820) - require.NoError(t, err) -} diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go deleted file mode 100644 index 0e635b6c8..000000000 --- a/client/internal/portforward/pcp/nat.go +++ /dev/null @@ -1,222 +0,0 @@ -package pcp - -import ( - "context" - "fmt" - "net" - "net/netip" - "runtime" - "sync" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/libp2p/go-nat" - "github.com/libp2p/go-netroute" -) - -var _ nat.NAT = (*NAT)(nil) - -// NAT implements the go-nat NAT interface using PCP. -// Supports dual-stack (IPv4 and IPv6) when available. -// All methods are safe for concurrent use. -// -// TODO: IPv6 pinholes use the local IPv6 address. If the address changes -// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale -// and needs to be recreated with the new address. -type NAT struct { - client *Client - - mu sync.RWMutex - // client6 is the IPv6 PCP client, nil if IPv6 is unavailable. - client6 *Client - // localIP6 caches the local IPv6 address used for PCP requests. - localIP6 netip.Addr -} - -// NewNAT creates a new NAT instance backed by PCP. -func NewNAT(gateway, localIP net.IP) *NAT { - client := NewClient(gateway) - client.SetLocalIP(localIP) - return &NAT{ - client: client, - } -} - -// Type returns "PCP" as the NAT type. -func (n *NAT) Type() string { - return "PCP" -} - -// GetDeviceAddress returns the gateway IP address. -func (n *NAT) GetDeviceAddress() (net.IP, error) { - return n.client.Gateway(), nil -} - -// GetExternalAddress returns the external IP address. -func (n *NAT) GetExternalAddress() (net.IP, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return n.client.GetExternalAddress(ctx) -} - -// GetInternalAddress returns the local IP address used to communicate with the gateway. -func (n *NAT) GetInternalAddress() (net.IP, error) { - addr, err := n.client.getLocalIP() - if err != nil { - return nil, err - } - return addr.AsSlice(), nil -} - -// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available). -func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { - resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout) - if err != nil { - return 0, fmt.Errorf("add mapping: %w", err) - } - - n.mu.RLock() - client6 := n.client6 - localIP6 := n.localIP6 - n.mu.RUnlock() - - if client6 == nil { - return int(resp.ExternalPort), nil - } - - if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil { - log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err) - return int(resp.ExternalPort), nil - } - - log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort) - return int(resp.ExternalPort), nil -} - -// DeletePortMapping removes a port mapping from both IPv4 and IPv6. -func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - err := n.client.DeletePortMapping(ctx, protocol, internalPort) - - n.mu.RLock() - client6 := n.client6 - n.mu.RUnlock() - - if client6 != nil { - if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil { - log.Warnf("IPv6 PCP delete mapping failed: %v", err6) - } - } - - if err != nil { - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive. -// Returns the current epoch and whether the server may have restarted (epoch state loss detected). -func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) { - epoch, err = n.client.Announce(ctx) - if err != nil { - return 0, false, fmt.Errorf("announce: %w", err) - } - return epoch, n.client.EpochStateLost(), nil -} - -// DiscoverPCP attempts to discover a PCP-capable gateway. -// Returns a NAT interface if PCP is supported, or an error otherwise. -// Discovers both IPv4 and IPv6 gateways when available. -func DiscoverPCP(ctx context.Context) (nat.NAT, error) { - gateway, localIP, err := getDefaultGateway() - if err != nil { - return nil, fmt.Errorf("get default gateway: %w", err) - } - - client := NewClient(gateway) - client.SetLocalIP(localIP) - if _, err := client.Announce(ctx); err != nil { - return nil, fmt.Errorf("PCP announce: %w", err) - } - - result := &NAT{client: client} - discoverIPv6(ctx, result) - - return result, nil -} - -func discoverIPv6(ctx context.Context, result *NAT) { - gateway6, localIP6, err := getDefaultGateway6() - if err != nil { - log.Debugf("IPv6 gateway discovery failed: %v", err) - return - } - - client6 := NewClient(gateway6) - client6.SetLocalIP(localIP6) - if _, err := client6.Announce(ctx); err != nil { - log.Debugf("PCP IPv6 announce failed: %v", err) - return - } - - addr, ok := netip.AddrFromSlice(localIP6) - if !ok { - log.Debugf("invalid IPv6 local IP: %v", localIP6) - return - } - result.mu.Lock() - result.client6 = client6 - result.localIP6 = addr - result.mu.Unlock() - log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6) -} - -// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table. -func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv4zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android. - // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties / - // NWPathMonitor) when netlink-based lookup is restricted or unavailable. - dst = net.IPv4(0, 0, 0, 1) - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} - -// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table. -func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv6zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // ::2 - dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} diff --git a/client/internal/portforward/pcp/protocol.go b/client/internal/portforward/pcp/protocol.go deleted file mode 100644 index d81c50c8c..000000000 --- a/client/internal/portforward/pcp/protocol.go +++ /dev/null @@ -1,225 +0,0 @@ -// Package pcp implements the Port Control Protocol (RFC 6887). -// -// # Implemented Features -// -// - ANNOUNCE opcode: Discovers PCP server support -// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6) -// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients -// - Nonce validation: Prevents response spoofing -// - Epoch tracking: Detects server restarts per Section 8.5 -// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1) -// -// # Not Implemented -// -// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal) -// - THIRD_PARTY option: For managing mappings on behalf of other devices -// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing) -// - FILTER option: To restrict remote peer addresses -// -// These optional features are omitted because the primary use case is simple -// port forwarding for WireGuard, which only requires MAP with default behavior. -package pcp - -import ( - "encoding/binary" - "fmt" - "net/netip" -) - -const ( - // Version is the PCP protocol version (RFC 6887). - Version = 2 - - // Port is the standard PCP server port. - Port = 5351 - - // DefaultLifetime is the default requested mapping lifetime in seconds. - DefaultLifetime = 7200 // 2 hours - - // Header sizes - headerSize = 24 - mapPayloadSize = 36 - mapRequestSize = headerSize + mapPayloadSize // 60 bytes -) - -// Opcodes -const ( - OpAnnounce = 0 - OpMap = 1 - OpPeer = 2 - OpReply = 0x80 // OR'd with opcode in responses -) - -// Protocol numbers for MAP requests -const ( - ProtoUDP = 17 - ProtoTCP = 6 -) - -// Result codes (RFC 6887 Section 7.4) -const ( - ResultSuccess = 0 - ResultUnsuppVersion = 1 - ResultNotAuthorized = 2 - ResultMalformedRequest = 3 - ResultUnsuppOpcode = 4 - ResultUnsuppOption = 5 - ResultMalformedOption = 6 - ResultNetworkFailure = 7 - ResultNoResources = 8 - ResultUnsuppProtocol = 9 - ResultUserExQuota = 10 - ResultCannotProvideExt = 11 - ResultAddressMismatch = 12 - ResultExcessiveRemotePeers = 13 -) - -// ResultCodeString returns a human-readable string for a result code. -func ResultCodeString(code uint8) string { - switch code { - case ResultSuccess: - return "SUCCESS" - case ResultUnsuppVersion: - return "UNSUPP_VERSION" - case ResultNotAuthorized: - return "NOT_AUTHORIZED" - case ResultMalformedRequest: - return "MALFORMED_REQUEST" - case ResultUnsuppOpcode: - return "UNSUPP_OPCODE" - case ResultUnsuppOption: - return "UNSUPP_OPTION" - case ResultMalformedOption: - return "MALFORMED_OPTION" - case ResultNetworkFailure: - return "NETWORK_FAILURE" - case ResultNoResources: - return "NO_RESOURCES" - case ResultUnsuppProtocol: - return "UNSUPP_PROTOCOL" - case ResultUserExQuota: - return "USER_EX_QUOTA" - case ResultCannotProvideExt: - return "CANNOT_PROVIDE_EXTERNAL" - case ResultAddressMismatch: - return "ADDRESS_MISMATCH" - case ResultExcessiveRemotePeers: - return "EXCESSIVE_REMOTE_PEERS" - default: - return fmt.Sprintf("UNKNOWN(%d)", code) - } -} - -// Response represents a parsed PCP response header. -type Response struct { - Version uint8 - Opcode uint8 - ResultCode uint8 - Lifetime uint32 - Epoch uint32 -} - -// MapResponse contains the full response to a MAP request. -type MapResponse struct { - Response - Nonce [12]byte - Protocol uint8 - InternalPort uint16 - ExternalPort uint16 - ExternalIP netip.Addr -} - -// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation. -func addrTo16(addr netip.Addr) [16]byte { - if addr.Is4() { - return netip.AddrFrom4(addr.As4()).As16() - } - return addr.As16() -} - -// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4. -func addrFrom16(b [16]byte) netip.Addr { - return netip.AddrFrom16(b).Unmap() -} - -// buildAnnounceRequest creates a PCP ANNOUNCE request packet. -func buildAnnounceRequest(clientIP netip.Addr) []byte { - req := make([]byte, headerSize) - req[0] = Version - req[1] = OpAnnounce - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - return req -} - -// buildMapRequest creates a PCP MAP request packet. -func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte { - req := make([]byte, mapRequestSize) - - // Header - req[0] = Version - req[1] = OpMap - binary.BigEndian.PutUint32(req[4:8], lifetime) - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - - // MAP payload - copy(req[24:36], nonce[:]) - req[36] = protocol - binary.BigEndian.PutUint16(req[40:42], internalPort) - binary.BigEndian.PutUint16(req[42:44], suggestedExtPort) - if suggestedExtIP.IsValid() { - extMapped := addrTo16(suggestedExtIP) - copy(req[44:60], extMapped[:]) - } - - return req -} - -// parseResponse parses the common PCP response header. -func parseResponse(data []byte) (*Response, error) { - if len(data) < headerSize { - return nil, fmt.Errorf("response too short: %d bytes", len(data)) - } - - resp := &Response{ - Version: data[0], - Opcode: data[1], - ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2) - Lifetime: binary.BigEndian.Uint32(data[4:8]), - Epoch: binary.BigEndian.Uint32(data[8:12]), - } - - if resp.Version != Version { - return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version) - } - - if resp.Opcode&OpReply == 0 { - return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode) - } - - return resp, nil -} - -// parseMapResponse parses a complete MAP response. -func parseMapResponse(data []byte) (*MapResponse, error) { - if len(data) < mapRequestSize { - return nil, fmt.Errorf("MAP response too short: %d bytes", len(data)) - } - - resp, err := parseResponse(data) - if err != nil { - return nil, fmt.Errorf("parse header: %w", err) - } - - mapResp := &MapResponse{ - Response: *resp, - Protocol: data[36], - InternalPort: binary.BigEndian.Uint16(data[40:42]), - ExternalPort: binary.BigEndian.Uint16(data[42:44]), - ExternalIP: addrFrom16([16]byte(data[44:60])), - } - copy(mapResp.Nonce[:], data[24:36]) - - return mapResp, nil -} diff --git a/client/internal/portforward/pinhole_test.go b/client/internal/portforward/pinhole_test.go new file mode 100644 index 000000000..46b07a9e7 --- /dev/null +++ b/client/internal/portforward/pinhole_test.go @@ -0,0 +1,116 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/netbirdio/go-nat" + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the +// shape a dual-stack gateway has. +type mockPinholeNAT struct { + *mockNAT + pinholeErr error +} + +func (m *mockPinholeNAT) IPv6PinholeError() error { + return m.pinholeErr +} + +func TestSetupLogsPinholeOutcome(t *testing.T) { + pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED") + + tests := []struct { + name string + pinholeErr error + mappingErr error + wantLevel log.Level + wantText string + }{ + { + name: "an open pinhole is reported", + wantLevel: log.InfoLevel, + wantText: "IPv6 pinhole open", + }, + { + name: "a failed pinhole is reported without failing the mapping", + // The IPv4 mapping is what the caller asked for, so the pinhole + // failure surfaces only in the log. + pinholeErr: pinholeErr, + wantLevel: log.WarnLevel, + wantText: pinholeErr.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr} + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, mapping, err := m.setup(context.Background()) + + require.NoError(t, err) + require.NotNil(t, mapping) + + entry := findEntry(hook, tt.wantText) + require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText) + assert.Equal(t, tt.wantLevel, entry.Level) + }) + } + + t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) { + // Nothing opened the pinhole, so whatever it currently reports says + // nothing about this attempt. + gateway := &mockPinholeNAT{mockNAT: newMockNAT()} + gateway.addMappingErr = errors.New("gateway refused") + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, _, err := m.setup(context.Background()) + + require.Error(t, err) + assert.Nil(t, findEntry(hook, "IPv6 pinhole")) + }) +} + +// stubGatewayDiscovery makes discovery return gateway and captures log output. +func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook { + t.Helper() + + orig := discoverGateway + discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil } + t.Cleanup(func() { discoverGateway = orig }) + + hook := test.NewGlobal() + origLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(origLevel) + }) + + return hook +} + +func findEntry(hook *test.Hook, substr string) *log.Entry { + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, substr) { + return entry + } + } + return nil +} diff --git a/client/internal/portforward/state.go b/client/internal/portforward/state.go index b1315cdc0..a21368e58 100644 --- a/client/internal/portforward/state.go +++ b/client/internal/portforward/state.go @@ -4,27 +4,94 @@ package portforward import ( "context" + "errors" "fmt" + "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" + "github.com/netbirdio/go-nat/pcp" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) // discoverGateway is the function used for NAT gateway discovery. // It can be replaced in tests to avoid real network operations. -// Tries PCP first, then falls back to NAT-PMP/UPnP. var discoverGateway = defaultDiscoverGateway -func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { - pcpGateway, err := pcp.DiscoverPCP(ctx) - if err == nil { - return pcpGateway, nil - } - log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err) +// pinholeDiscoveryTimeout is the slice of the discovery budget held back for +// the IPv6 pinhole probe. +// +// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout +// and a 3s first backoff, so a second attempt needs about 9s. Anything from +// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how +// long that attempt waits. A PCP server sits on the local link and answers in +// milliseconds, so 3s is margin rather than need, and the rest is left to +// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost +// to a dropped packet is retried by the next discovery round. +// +// It is a variable so tests can shorten it. +var pinholeDiscoveryTimeout = 3 * time.Second - return nat.DiscoverGateway(ctx) +// Discovery entry points, as variables so tests can drive the fallback without +// touching the network. +var ( + discoverNATGateway = nat.DiscoverGateway + + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + pinhole, err := pcp.DiscoverPCP(ctx) + if err != nil { + return nil, err + } + return pinhole, nil + } +) + +// defaultDiscoverGateway finds a gateway that can make the WireGuard port +// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP +// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol +// wins. +// +// It reports no gateway on a network offering only IPv6, having no IPv4 mapping +// to attach a pinhole to. Such a network still needs one: there is no +// translation to traverse, but the router drops inbound IPv6 until something +// opens it. Fall back to PCP alone, which yields a gateway holding just the +// pinhole. +func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { + gatewayCtx, cancel := reserveForPinhole(ctx) + defer cancel() + + gateway, err := discoverNATGateway(gatewayCtx) + if err == nil { + return gateway, nil + } + if !errors.Is(err, nat.ErrNoNATFound) { + return nil, err + } + + pinhole, pinholeErr := discoverPCPPinhole(ctx) + if pinholeErr != nil { + log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr) + return nil, err + } + + log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only") + return pinhole, nil +} + +// reserveForPinhole shortens ctx so that a pinhole probe still has time to run +// afterwards. Finding nothing takes gateway discovery everything it is given, +// so on the unshortened context the probe would start already expired. A budget +// too small to divide is left to gateway discovery, which is the likelier win. +func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) { + deadline, ok := ctx.Deadline() + if !ok { + return context.WithCancel(ctx) + } + + remaining := time.Until(deadline) + if remaining <= pinholeDiscoveryTimeout { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout) } // State is persisted only for crash recovery cleanup diff --git a/client/internal/portforward/state_test.go b/client/internal/portforward/state_test.go new file mode 100644 index 000000000..8a584eecb --- /dev/null +++ b/client/internal/portforward/state_test.go @@ -0,0 +1,140 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netbirdio/go-nat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubDiscovery replaces both discovery entry points for the duration of a +// test. gatewayDelay simulates gateway discovery spending everything it is +// given before reporting that it found nothing. +func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) { + t.Helper() + + origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole + discoverNATGateway = func(ctx context.Context) (nat.NAT, error) { + if gatewayDelay > 0 { + select { + case <-time.After(gatewayDelay): + case <-ctx.Done(): + } + } + return gateway, gatewayErr + } + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return pinhole, pinholeErr + } + + t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole }) +} + +func TestDefaultDiscoverGateway(t *testing.T) { + ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"} + ipv6Pinhole := &mockNAT{natType: "PCP"} + otherErr := errors.New("routing table unavailable") + + t.Run("an IPv4 gateway is used as is", func(t *testing.T) { + stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv4Gateway, got) + }) + + t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) + + t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route")) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found") + }) + + t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) { + stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, otherErr) + }) + + t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) { + // On one shared context the probe would start already expired, which is + // how this failed against a real gateway. + reserve := 50 * time.Millisecond + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = reserve + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + budget := 4 * reserve + ctx, cancel := context.WithTimeout(context.Background(), budget) + defer cancel() + + stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(ctx) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) +} + +func TestReserveForPinhole(t *testing.T) { + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = time.Second + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + t.Run("a budget is divided", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond)) + }) + + t.Run("a budget too small to divide is left whole", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond)) + }) + + t.Run("no deadline stays unbounded", func(t *testing.T) { + gatewayCtx, cancelGateway := reserveForPinhole(context.Background()) + defer cancelGateway() + + _, ok := gatewayCtx.Deadline() + assert.False(t, ok) + }) +} diff --git a/go.mod b/go.mod index e8d65e568..265cd962f 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,6 @@ require ( github.com/hashicorp/go-version v1.7.0 github.com/jackc/pgx/v5 v5.5.5 github.com/libdns/route53 v1.5.0 - github.com/libp2p/go-nat v0.2.0 github.com/libp2p/go-netroute v0.4.0 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 github.com/mdlayher/socket v0.5.1 @@ -81,6 +80,7 @@ require ( github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.54.1 + github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 diff --git a/go.sum b/go.sum index 99adaa2cb..d9d880ede 100644 --- a/go.sum +++ b/go.sum @@ -407,8 +407,6 @@ github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA= github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q= -github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= -github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU= @@ -480,6 +478,8 @@ github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUk github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= From ee253feddfd08a1b559d4014cbcf8d64cabed937 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sat, 22 Aug 2026 21:07:31 +0200 Subject: [PATCH 29/36] [misc] Pin the toolchain gomobile init needs for gobind (#7291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [misc] Let gomobile init fetch the toolchain gobind needs The previous commit's CI run confirmed the failure on a comment-only diff off main, so the cause is not any branch's changes: Android / Build failure iOS / Build failure `gomobile init` re-installs gobind from x/mobile@latest whatever gomobile is pinned to, and setup-go sets GOTOOLCHAIN=local, so the install dies the moment @latest declares a newer Go than go.mod does: gomobile: go install golang.org/x/mobile/cmd/gobind@latest failed: exit status 1 go: golang.org/x/mobile@v0.0.0-20260821190718-4776eadac327 requires go >= 1.26.0 (running go 1.25.12; GOTOOLCHAIN=local) GOTOOLCHAIN=auto on that step alone lets the install fetch what it asks for. Scoped to the step deliberately: the repo's Go version and every build below it stay on go.mod's toolchain, so this buys the mobile jobs nothing except the ability to run gobind. Pinning gobind next to gomobile does not work — init re-installs @latest regardless. A durable fix is to stop `init` reaching the network at all, or to track x/mobile's Go requirement in go.mod; both are larger changes than a red CI warrants right now. --- .github/workflows/mobile-build-validation.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml index 322f129c9..204576d28 100644 --- a/.github/workflows/mobile-build-validation.yml +++ b/.github/workflows/mobile-build-validation.yml @@ -43,8 +43,19 @@ jobs: run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620" - name: install gomobile run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab + # `gomobile init` re-installs gobind from golang.org/x/mobile@latest + # regardless of the pin above (cmd/gomobile/init.go: "Make sure gobind is + # up to date"), so this step resolves a version nobody chose, on every run. + # + # setup-go sets GOTOOLCHAIN=local, so that install fails outright once + # x/mobile@latest declares a newer Go than go.mod does — which it did on + # 2026-08-21, breaking both jobs on every branch at once. GOTOOLCHAIN=auto + # lets this one install fetch the toolchain it asks for. Scoped to the + # step: the repo's own Go version, and every build below, is unaffected. - name: gomobile init run: gomobile init + env: + GOTOOLCHAIN: auto - name: build android netbird lib run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android env: @@ -64,8 +75,13 @@ jobs: go-version-file: "go.mod" - name: install gomobile run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab + # See the Android job: `gomobile init` re-installs gobind from + # golang.org/x/mobile@latest regardless of the pin above, and needs a + # toolchain it may pick newer than go.mod's. - name: gomobile init run: gomobile init + env: + GOTOOLCHAIN: auto - name: build iOS netbird lib run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK env: From 766fcae3f8a9d6ba445fe8b2f2d872506f8d72e3 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 23 Aug 2026 20:02:33 +0200 Subject: [PATCH 30/36] [proxy,management] Conform the Agent Network endpoint to the LLM gateway protocol (#7154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [proxy,management] Conform the Agent Network endpoint to the LLM gateway protocol Reviewed the proxy against Claude Code's published gateway contract. The transport layer already held up; fourteen gaps sat one layer up, in the model catalog and in the non-inference endpoints clients call. Two of them cost money. The catalog carried no claude-opus-5 or claude-sonnet-5, so an operator could not authorise the models coding agents default to — those requests denied as not-routable, or priced at zero where a catch-all carried them. And gateway records pin ParserID "openai" while the same record serves /v1/messages, so Anthropic responses were read with the OpenAI parser, which never looks at message_start where input tokens live: input metered as roughly zero on every stream and cost was skipped entirely. The rest fix requests refused for structural rather than policy reasons: model discovery denied for every account with a model allowlist, token counting denied on Bedrock and mis-parsed on Vertex, startup probes refused and written into the access log at every session start, and denials rendered in a shape no LLM client parses. Two changes are additive by design — the deny body keeps every field it had and adds the vendor's error object alongside, and body-level identity injection is now gated on the request's dialect so it stops sending OpenAI-shape fields into Anthropic bodies that reject them. The end-to-end work turned up one more: the discovery filter treated any slash in a model id as a gateway prefix, which would have dropped every self-hosted "Qwen/..." model from the picker. --- agent-network/README.md | 29 ++ e2e/agentnetwork/custom_pricing_test.go | 189 +++++++- e2e/agentnetwork/gateway_protocol_test.go | 455 ++++++++++++++++++ e2e/agentnetwork/gateway_review_test.go | 242 ++++++++++ e2e/agentnetwork/main_test.go | 16 + e2e/agentnetwork/streaming_test.go | 209 ++++++++ e2e/harness/client.go | 53 +- e2e/harness/vllm.go | 135 +++++- .../modules/agentnetwork/catalog/catalog.go | 6 + .../agentnetwork/catalog/catalog_test.go | 36 ++ .../modules/agentnetwork/pricing/defaults.go | 6 - .../pricing/defaults_llm_pricing.example.yaml | 10 + .../agentnetwork/pricing/defaults_test.go | 8 +- proxy/internal/llm/model.go | 8 + proxy/internal/llm/pricing/pricing.go | 16 +- proxy/internal/llm/pricing/pricing_test.go | 19 + .../builtin/cost_meter/middleware.go | 18 +- .../builtin/llm_guardrail/middleware.go | 26 +- .../builtin/llm_guardrail/middleware_test.go | 49 ++ .../builtin/llm_identity_inject/middleware.go | 31 ++ .../llm_identity_inject/middleware_test.go | 54 +++ .../builtin/llm_limit_check/middleware.go | 14 +- .../llm_limit_check/middleware_test.go | 32 ++ .../llm_request_parser/bedrock_test.go | 26 + .../builtin/llm_request_parser/middleware.go | 76 ++- .../llm_request_parser/middleware_test.go | 105 ++++ .../builtin/llm_router/bedrock_route_test.go | 87 ++++ .../builtin/llm_router/middleware.go | 302 +++++++++--- .../builtin/llm_router/middleware_test.go | 276 ++++++++++- proxy/internal/middleware/decision.go | 68 +++ proxy/internal/middleware/decision_test.go | 92 ++++ proxy/internal/middleware/keys.go | 17 + proxy/internal/middleware/types.go | 12 + proxy/internal/proxy/discovery_filter.go | 215 +++++++++ proxy/internal/proxy/discovery_filter_test.go | 235 +++++++++ proxy/internal/proxy/reverseproxy.go | 3 + proxy/server.go | 26 +- shared/llm/model.go | 21 + shared/llm/model_test.go | 26 + 39 files changed, 3094 insertions(+), 154 deletions(-) create mode 100644 e2e/agentnetwork/gateway_protocol_test.go create mode 100644 e2e/agentnetwork/gateway_review_test.go create mode 100644 e2e/agentnetwork/streaming_test.go create mode 100644 management/internals/modules/agentnetwork/catalog/catalog_test.go create mode 100644 proxy/internal/middleware/decision_test.go create mode 100644 proxy/internal/proxy/discovery_filter.go create mode 100644 proxy/internal/proxy/discovery_filter_test.go diff --git a/agent-network/README.md b/agent-network/README.md index 1997ea299..5211fe8f9 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that Full step-by-step setup: **https://docs.netbird.io/agent-network/quickstart** +## Client settings that don't follow the endpoint + +Most of an agent's traffic follows the base URL you hand it, but a few +client-side checks call their vendor directly and never reach the proxy. On a +network that blocks direct egress they fail even though inference works, so +they are worth setting once when you roll the endpoint out. + +For Claude Code: + +- **Fast mode** checks availability against `api.anthropic.com` rather than the + configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the + agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when + the proxy injects the real provider key) or when a TLS-inspecting proxy + answers the check itself. Set + `CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the + connection outright. Fast mode is an Anthropic-API feature, so it is + unavailable on a Bedrock- or Vertex-backed endpoint whatever you set. +- **Model discovery** is off by default. Set + `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the + models your policies authorise; the proxy filters the response to that set. + The client gives discovery a three-second budget and treats any redirect as + a failure, so the endpoint must serve `/v1/models` directly. +- **The WebFetch domain safety check** also calls `api.anthropic.com` directly + and is unaffected by the variables above. + +Allowing direct egress to `api.anthropic.com` covers the network cases but not +the credential one, where the check reaches Anthropic and is rejected because +the agent presents a proxy-issued key. + ## Architecture Agent Network is built on two existing NetBird capabilities: diff --git a/e2e/agentnetwork/custom_pricing_test.go b/e2e/agentnetwork/custom_pricing_test.go index e3750258f..b3ca5028f 100644 --- a/e2e/agentnetwork/custom_pricing_test.go +++ b/e2e/agentnetwork/custom_pricing_test.go @@ -23,9 +23,10 @@ import ( // model the client asks for. The proxy prices off the REQUEST model, not the // upstream response model, so a made-up model id billed at operator rates lets // these tests assert exact costs without a real vendor key. +// Sourced from the harness so the counts can't drift from the mock's config. const ( - vllmPromptTokens = 11 - vllmCompletionTokens = 2 + vllmPromptTokens = harness.VLLMChatInputTokens + vllmCompletionTokens = harness.VLLMChatOutputTokens ) // pricedEnv is a connected single-provider agent-network deployment pointed at @@ -162,30 +163,85 @@ func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID break } } - time.Sleep(5 * time.Second) + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } } require.Equal(t, 200, code, "chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background())) return body } -// findAccessLogBySession polls the access-log page for the row carrying sessionID. -func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog { - t.Helper() - var row api.AgentNetworkAccessLog - require.Eventually(t, func() bool { - logs, lerr := srv.ListAccessLogs(ctx) - if lerr != nil { - return false - } - for _, r := range logs.Data { - if r.SessionId != nil && *r.SessionId == sessionID { - row = r - return true +// accessLogIngestWindow is how long a single request's access-log row is given +// to appear before the caller gives up on it. +const accessLogIngestWindow = 30 * time.Second + +// accessLogPollInterval is how long the lookup waits between pages. Ingest is +// asynchronous, so the row lands somewhere inside the window rather than on +// any particular poll. +const accessLogPollInterval = 2 * time.Second + +// lookupAccessLogBySession polls the access-log page for the row carrying +// sessionID and reports whether it arrived within the window. It never fails +// the test: callers that can recover — by firing a fresh request under a new +// session — need to see the miss rather than die on it. +func lookupAccessLogBySession(ctx context.Context, sessionID string, within time.Duration) (api.AgentNetworkAccessLog, bool) { + deadline := time.Now().Add(within) + for { + // Each poll is bounded by what is left of the window rather than by the + // caller's context: a single stalled request would otherwise hold the + // loop open long past the ingest window it is meant to enforce, and the + // caller would read the delay as a missing row. + if logs, lerr := listAccessLogsBy(ctx, deadline); lerr == nil { + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return r, true + } } } - return false - }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID) + // The wait is bounded by the window as well, so the answer arrives when + // the caller's budget runs out rather than a poll interval later: a + // full interval slept past the deadline reports "no row" up to two + // seconds late, which reads as a slower lookup than the one asked for. + wait := time.Until(deadline) + if wait > accessLogPollInterval { + wait = accessLogPollInterval + } + if wait <= 0 { + return api.AgentNetworkAccessLog{}, false + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return api.AgentNetworkAccessLog{}, false + case <-timer.C: + } + // Checked after the wait rather than before the request: a poll issued + // past the deadline carries no budget and would fail on arrival. + if !time.Now().Before(deadline) { + return api.AgentNetworkAccessLog{}, false + } + } +} + +// listAccessLogsBy fetches one access-log page under a context that expires at +// deadline, so no single call can outlive the window its caller is polling +// within. The parent's cancellation still applies: the child inherits it. +func listAccessLogsBy(ctx context.Context, deadline time.Time) (api.AgentNetworkAccessLogsResponse, error) { + reqCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + return srv.ListAccessLogs(reqCtx) +} + +// findAccessLogBySession polls the access-log page for the row carrying +// sessionID, failing the test if it never lands. Use it for a request whose row +// must exist; where a missing row is a recoverable race, use +// lookupAccessLogBySession and retry. +func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog { + t.Helper() + row, ok := lookupAccessLogBySession(ctx, sessionID, accessLogIngestWindow) + require.True(t, ok, "session id %q must be recorded in an access-log row", sessionID) return row } @@ -319,6 +375,11 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) { outRateA = 0.020 inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable outRateB = 0.080 + // Per-attempt ingest wait, shorter than the default so a request that + // produces no row costs one retry rather than most of the budget, and an + // overall deadline long enough to hold several attempts. + repriceIngestWindow = 20 * time.Second + repriceDeadline = 180 * time.Second ) env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{ @@ -353,27 +414,61 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) { // reading its cost, so an un-ingested row is never mistaken for "still rate A". // The expected new input cost is unmistakably higher than rate A, so a // lingering old-rate row can't satisfy the check. + // + // Every way an iteration can come up short — the request failing, its row not + // landing, or the row still carrying rate A — is a symptom of the same + // in-flight rebuild, so each one retries under a fresh session rather than + // ending the test. Only the outer deadline is fatal. wantInputB := float64(vllmPromptTokens) / 1000 * inRateB var repriced api.AgentNetworkAccessLog var lastSession string - deadline := time.Now().Add(90 * time.Second) + // The cost last read, kept separately: repriced is the zero value on every + // path that gives up, so reporting its cost would say "$0.000000" whether + // the rows were still at rate A or no row was ever read. + var lastCost float64 + var sawRow bool + deadline := time.Now().Add(repriceDeadline) + // Everything inside the loop runs under the deadline rather than the + // test's own context. An attempt started just before it would otherwise + // run well past it: the chat container is capped at 90s of its own and the + // row lookup at another 20s, so the loop could report a repricing failure + // nearly two minutes after the window it was given had closed. + repriceCtx, cancelReprice := context.WithDeadline(ctx, deadline) + defer cancelReprice() for time.Now().Before(deadline) { lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano()) - code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession) + code, _, cerr := env.client.Chat(repriceCtx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession) if cerr != nil || code != 200 { - time.Sleep(5 * time.Second) + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } + continue + } + row, ok := lookupAccessLogBySession(repriceCtx, lastSession, repriceIngestWindow) + if !ok { + // No row for this request. The proxy now publishes a rebuilt chain + // before the route that reaches it, so a request can no longer be + // served unattributed mid-update; this retry covers the ingest + // window alone. Fire another one under a fresh session. + t.Logf("no access-log row for session %q within %s; retrying under a fresh session", lastSession, repriceIngestWindow) continue } - row := findAccessLogBySession(t, ctx, lastSession) if inDelta(row.InputCostUsd, wantInputB, 1e-6) { repriced = row break } // Still priced at the old rate — the push hasn't landed yet; retry. - time.Sleep(5 * time.Second) + lastCost, sawRow = row.InputCostUsd, true + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } } - require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; last input_cost_usd=$%.6f, wanted $%.6f\n=== proxy logs ===\n%s", - repriced.InputCostUsd, wantInputB, env.proxy.Logs(context.Background())) + lastSeen := "no row was ever read" + if sawRow { + lastSeen = fmt.Sprintf("last input_cost_usd=$%.6f", lastCost) + } + require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; %s, wanted $%.6f\n=== proxy logs ===\n%s", + lastSeen, wantInputB, env.proxy.Logs(context.Background())) assertOpenAICostAtRates(t, repriced, inRateB, outRateB) verifyUsageRowForSession(t, lastSession, inRateB, outRateB) @@ -630,3 +725,47 @@ func inDelta(a, b, tol float64) bool { } return d <= tol } + +// TestCustomDatedModelKeepsItsOwnPrice covers the review fix that anchored the +// release-date fallback to Claude ids. Pricing looks every model up through +// that helper, so while it matched a bare trailing date any operator id ending +// in eight digits inherited the rate of its undated sibling — a silent +// mis-bill on models NetBird knows nothing about. +func TestCustomDatedModelKeepsItsOwnPrice(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + baseModel = "internal-llm" + datedModel = "internal-llm-20250101" + baseIn = 0.010 + baseOut = 0.020 + // An order of magnitude apart, so a row billed at the wrong entry is + // unmistakable rather than a rounding argument. + datedIn = 0.100 + datedOut = 0.200 + ) + + env := provisionPricedProvider(t, ctx, "customdated", []api.AgentNetworkProviderModel{ + {Id: baseModel, InputPer1k: baseIn, OutputPer1k: baseOut}, + {Id: datedModel, InputPer1k: datedIn, OutputPer1k: datedOut}, + }) + + t.Run("the undated id bills at its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-base-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, baseModel, session) + assertOpenAICostAtRates(t, findAccessLogBySession(t, ctx, session), baseIn, baseOut) + }) + + t.Run("the dated id keeps its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-dated-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, datedModel, session) + row := findAccessLogBySession(t, ctx, session) + assertOpenAICostAtRates(t, row, datedIn, datedOut) + + // Spelled out because it is the regression: inheriting the sibling's + // rate would bill this request at a tenth of its price. + assert.Greater(t, row.InputCostUsd, float64(vllmPromptTokens)/1000*baseIn*2, + "a custom dated id must not inherit the undated entry's rate") + }) +} diff --git a/e2e/agentnetwork/gateway_protocol_test.go b/e2e/agentnetwork/gateway_protocol_test.go new file mode 100644 index 000000000..c21a4fc53 --- /dev/null +++ b/e2e/agentnetwork/gateway_protocol_test.go @@ -0,0 +1,455 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "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" +) + +// Models each catalog surface is registered with in the matrix below. They +// differ per provider so the router's choice is unambiguous: a request that +// lands on the wrong provider record fails the surface assertion instead of +// passing by coincidence. +const ( + matrixAnthropicModel = "claude-sonnet-5" + matrixBedrockModel = "anthropic.claude-sonnet-5" + // matrixBedrockPathModel is what a Bedrock SDK client puts in the URL: a + // cross-region inference profile with a release date and version suffix. + // The proxy must normalise it back to matrixBedrockModel to route and price. + matrixBedrockPathModel = "us.anthropic.claude-sonnet-5-20250101-v1:0" + // matrixVertexModel differs from the Anthropic record's model on purpose: + // a shared id would leave two routes claiming it and make which one serves + // /v1/messages depend on declaration order. + matrixVertexModel = "claude-haiku-4-5" + matrixVertexProject = "e2e-project" + matrixVertexRegion = "us-east5" +) + +// gatewayEnv is a connected client plus a set of provider records, all pointed +// at one mock upstream, so several wire shapes can be driven over a single +// tunnel. +type gatewayEnv struct { + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy + vllm *harness.VLLM + // providerIDs maps the catalog id to the created provider record id. + providerIDs map[string]string +} + +// provisionGatewayMatrix brings up one mock upstream and one provider record +// per catalog surface, all authorised for the same group by a single policy. +// Sharing one proxy and client keeps the wire-shape cases to one tunnel setup; +// each case still creates its own session id so its access-log row is findable. +func provisionGatewayMatrix(t *testing.T, ctx context.Context) gatewayEnv { + t.Helper() + + 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-gw-matrix"}) + 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-gw-matrix-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") + + // The mock ignores auth, so a dummy credential satisfies each catalog + // entry's auth template. Vertex is the exception: its api_key is a GCP + // service-account keyfile the proxy mints an OAuth token from, and a dummy + // one cannot mint. That is deliberate — the Vertex case below asserts on + // routing, which happens before the token mint. + dummyKey := "sk-gw-e2e" + dummyKeyfile := "keyfile::" + "e2e-not-a-real-service-account-key" + + specs := []struct { + name string + catalogID string + apiKey string + models []api.AgentNetworkProviderModel + }{ + { + name: "openai", catalogID: "openai_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}}, + }, + { + name: "anthropic", catalogID: "anthropic_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixAnthropicModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "bedrock", catalogID: "bedrock_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixBedrockModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "vertex", catalogID: "vertex_ai_api", apiKey: dummyKeyfile, + models: []api.AgentNetworkProviderModel{{Id: matrixVertexModel, InputPer1k: 0.001, OutputPer1k: 0.005}}, + }, + } + + providerIDs := make(map[string]string, len(specs)) + ids := make([]string, 0, len(specs)) + for _, spec := range specs { + key := spec.apiKey + models := spec.models + prov, perr := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-" + spec.name, + ProviderId: spec.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &key, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, perr, "create %s provider", spec.name) + id := prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + providerIDs[spec.catalogID] = id + ids = append(ids, id) + } + + // Uncapped token limit: never blocks the handful of tokens driven here, but + // switches on usage metering so consumption and cost land in the row. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gw-matrix", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-matrix", sk.Key) + return gatewayEnv{ + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + vllm: vllm, + providerIDs: providerIDs, + } +} + +// connectClient starts a proxy and a tunnel client for the shared account and +// waits until the client can reach the proxy peer, returning the endpoint and +// the proxy's tunnel IP to pin requests to. +func connectClient(t *testing.T, ctx context.Context, name, setupKey string) (string, string, *harness.Client, *harness.Proxy) { + t.Helper() + + 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-"+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, setupKey) + 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") + // The probe 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())) + } + return settings.Endpoint, proxyIP, cl, px +} + +// callUntil retries an HTTP call through the tunnel until it returns one of the +// wanted statuses or the deadline passes, absorbing the DNS and tunnel jitter +// the first call through a fresh tunnel can hit. The last status and body are +// returned either way so the caller can assert with real detail. +func callUntil(t *testing.T, call func() (int, string, error), want ...int) (int, string) { + t.Helper() + wanted := make(map[int]struct{}, len(want)) + for _, w := range want { + wanted[w] = struct{}{} + } + + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, err := call() + if err == nil { + code, body = c, b + if _, ok := wanted[code]; ok { + return code, body + } + } + time.Sleep(5 * time.Second) + } + return code, body +} + +// TestGatewayProtocolProviderMatrix drives one request per wire shape over a +// single tunnel, with a provider record per catalog surface behind it. It is +// the regression net for the routing and parser-selection changes: each case +// asserts the surface the request was metered under and the token counts that +// surface's own usage block carries, so a request parsed by the wrong provider's +// parser meters zero and fails rather than passing on a coincidence. +func TestGatewayProtocolProviderMatrix(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionGatewayMatrix(t, ctx) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + env.vllm.Logs(context.Background()), env.proxy.Logs(context.Background())) + } + + t.Run("openai chat completions", func(t *testing.T) { + session := "e2e-gw-openai" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, harness.VLLMModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "openai chat must succeed; body: %s%s", body, diag()) + require.Contains(t, body, "chat.completion", "body must be an OpenAI completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "openai", *row.Provider, "the OpenAI chat path must meter under the openai surface") + assert.Equal(t, int64(harness.VLLMChatInputTokens), row.InputTokens, "OpenAI usage block must be read") + assert.Equal(t, int64(harness.VLLMChatOutputTokens), row.OutputTokens) + }) + + t.Run("anthropic messages", func(t *testing.T) { + session := "e2e-gw-anthropic" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, matrixAnthropicModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "anthropic messages must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "anthropic", *row.Provider, "the /v1/messages path must meter under the anthropic surface") + // These counts only appear if the Anthropic parser read the response: + // its usage fields are named differently from the OpenAI block. + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens, + "Anthropic input_tokens must be read; zero here means the wrong parser ran") + assert.Equal(t, int64(harness.VLLMMessagesOutputTokens), row.OutputTokens) + assert.Positive(t, row.CachedInputTokens, "the Anthropic cache-read bucket must be recorded") + assert.Positive(t, row.CostUsd, "a metered request must carry a cost") + require.NotNil(t, row.ResolvedProviderId) + assert.Equal(t, env.providerIDs["anthropic_api"], *row.ResolvedProviderId, + "a vendor-tagged request must not cross to another provider's record") + }) + + t.Run("bedrock invoke normalises the path model", func(t *testing.T) { + session := "e2e-gw-bedrock" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Bedrock(ctx, env.endpoint, env.proxyIP, matrixBedrockPathModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "bedrock invoke must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "bedrock", *row.Provider, "a native Bedrock path must meter under the bedrock surface") + require.NotNil(t, row.Model) + assert.Equal(t, matrixBedrockModel, *row.Model, + "the inference-profile prefix, release date and version suffix must be normalised away") + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens) + }) + + t.Run("anthropic token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/messages/count_tokens", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"ping"}]}`, matrixAnthropicModel), + []string{"anthropic-version: 2023-06-01"}) + }, 200) + assert.Equal(t, 200, code, "token counting must route rather than deny; body: %s%s", body, diag()) + }) + + t.Run("bedrock token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, + "/model/"+matrixBedrockPathModel+"/count-tokens", + `{"input":{"converse":{"messages":[{"role":"user","content":[{"text":"ping"}]}]}}}`, nil) + }, 200) + assert.Equal(t, 200, code, + "the Bedrock count-tokens action must route; denying it pushes counting onto the billable inference path; body: %s%s", + body, diag()) + }) + + t.Run("vertex token counting reaches its provider", func(t *testing.T) { + // The dummy service-account key cannot mint an OAuth token, so the + // request stops at the upstream credential. Both outcomes render as + // 403, so the deny code is what distinguishes them: upstream_auth_failed + // means the path resolved to the Vertex route and only the credential + // failed, while model_not_routable would mean the method segment was + // swallowed into the model id and no route ever claimed it. + path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s/count-tokens:rawPredict", + matrixVertexProject, matrixVertexRegion, matrixVertexModel) + _, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"ping"}]}`, nil) + }, 403) + assert.NotContains(t, body, "model_not_routable", + "the count-tokens method segment must not be parsed as part of the model id; body: %s%s", body, diag()) + assert.Contains(t, body, "llm_policy.upstream_auth_failed", + "the request must reach the Vertex route and fail only at the credential; body: %s%s", body, diag()) + }) + + t.Run("connection warming probe", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/api/hello", nil) + }, 200) + assert.NotEqual(t, 403, code, + "the warm-up probe carries no model and must not be refused as unroutable; body: %s%s", body, diag()) + }) + + t.Run("unknown model denies in the caller's error shape", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, + "claude-not-a-real-model-9", "ping", "e2e-gw-unknown") + }, 403) + require.Equal(t, 403, code, "a model no provider claims must still be refused; body: %s%s", body, diag()) + + // The NetBird fields stay where they were for existing consumers. + assert.Contains(t, body, "llm_policy.model_not_routable", "the deny code must be preserved") + // And the vendor's own envelope rides alongside, so the client can show + // the reason instead of an unexplained API error. + assert.Contains(t, body, `"type":"error"`, "an Anthropic caller must get the Anthropic error envelope") + assert.Contains(t, body, "permission_error", "403 must map to the vendor's permission error type") + }) +} + +// TestModelDiscoveryWithModelAllowlist covers gateway model discovery on an +// account that restricts models, which is the configuration that broke: the +// listing carries no model, and the per-model allowlist fails closed on an +// undetermined one, so discovery denied for exactly the accounts using the +// feature. It also asserts the allowlist still refuses a model outside it, so +// the exemption cannot be read as a way around the gate. +func TestModelDiscoveryWithModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*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-gw-discovery"}) + 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-gw-discovery-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") + + // One provider enumerating a single model, while the upstream's own listing + // advertises two. The proxy must serve the shorter list. + dummyKey := "sk-discovery-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-discovery", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // The model allowlist is what makes this a regression test: without a + // guardrail enabled, discovery was never gated in the first place. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-gw-discovery-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + 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-gw-discovery", + 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) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-discovery", sk.Key) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + vllm.Logs(context.Background()), px.Logs(context.Background())) + } + + t.Run("listing is served and bounded by policy", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, + "discovery must not be refused because the request carries no model; body: %s%s", body, diag()) + + assert.Contains(t, body, harness.VLLMModel, "the authorised model must reach the picker") + assert.NotContains(t, body, harness.VLLMUnlistedModel, + "a model the policy does not authorise must not be offered; body: %s", body) + }) + + t.Run("allowlist still refuses a model outside it", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMUnlistedModel, "ping", "e2e-gw-discovery-blocked") + }, 403) + require.Equal(t, 403, code, + "exempting model-less endpoints must not exempt inference; body: %s%s", body, diag()) + assert.True(t, + strings.Contains(body, "llm_policy.model_blocked") || strings.Contains(body, "llm_policy.model_not_routable"), + "the refusal must name a model policy code; body: %s", body) + }) + + t.Run("allowlisted model still routes", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMModel, "ping", "e2e-gw-discovery-allowed") + }, 200) + require.Equal(t, 200, code, "the allowlisted model must still be served; body: %s%s", body, diag()) + }) +} diff --git a/e2e/agentnetwork/gateway_review_test.go b/e2e/agentnetwork/gateway_review_test.go new file mode 100644 index 000000000..556bc4a53 --- /dev/null +++ b/e2e/agentnetwork/gateway_review_test.go @@ -0,0 +1,242 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "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" +) + +// The cases in this file cover behaviour that arrived from code review, after +// the gateway-protocol end-to-end tests were written. Each had unit coverage +// only; none needed a new harness capability, which is why they belong here +// rather than on a manual checklist. + +// TestNonInferenceEndpointsAreAuthorised covers the two review findings on the +// endpoints that carry no body: the per-model lookup must be authorised +// against the same allowlist that bounds the listing beside it, and only a read +// method may claim the non-inference exemption that skips the token pre-flight. +func TestNonInferenceEndpointsAreAuthorised(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionDiscoveryProvider(t, ctx) + + t.Run("lookup of an authorised model succeeds", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMModel, nil) + }, 200) + assert.Equal(t, 200, code, "an allowlisted model must remain reachable; body: %s", body) + }) + + t.Run("lookup of an unauthorised model is refused", func(t *testing.T) { + code, body, err := env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMUnlistedModel, nil) + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a model the policy does not authorise must not be confirmed by the detail lookup; body: %s", body) + }) + + // A write must not claim the exemption that lets the listing skip the token + // pre-flight. The body names no model on purpose: that is what a request + // probing for the exemption looks like, and it is the case the method gate + // exists to refuse. (A POST that does name a model is a different thing — + // it routes and meters as the inference request it is.) + for _, path := range []string{"/v1/models", "/v1/models/" + harness.VLLMModel, "/api/hello"} { + t.Run("write to "+path+" is refused", func(t *testing.T) { + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"messages":[{"role":"user","content":"hi"}]}`, nil) + require.NoError(t, err, "request must reach the proxy") + assert.NotEqual(t, 200, code, + "a write to a non-inference path must not be served unmetered; body: %s", body) + }) + } + + // A request carrying the sub-agent attribution headers must still be served + // and metered normally. Asserting the ids themselves is not possible yet: + // the parser lifts them onto the request's metadata, but nothing persists + // them, so they have no queryable surface to check against. + t.Run("sub-agent headers do not disturb the request", func(t *testing.T) { + sessionID := fmt.Sprintf("e2e-session-agentid-%d", time.Now().UnixNano()) + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/chat/completions", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, harness.VLLMModel), + []string{ + "x-session-id: " + sessionID, + "x-claude-code-agent-id: agent-child-7", + "x-claude-code-parent-agent-id: agent-root-1", + }) + require.NoError(t, err, "request must reach the proxy") + require.Equal(t, 200, code, "the request must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the request must still be metered normally") + }) +} + +// TestDatedModelIdRouting covers both halves of the dated-id rule that review +// tightened: a dated id still reaches an undated registration, but a route +// pinned to one dated build must never serve a different one. +func TestDatedModelIdRouting(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + undated = "claude-sonnet-9" + datedA = "claude-sonnet-9-20250101" + datedB = "claude-sonnet-9-20250202" + ) + + t.Run("a dated id reaches its undated registration", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-undated", "anthropic_api", undated) + + sessionID := fmt.Sprintf("e2e-session-dated-%d", time.Now().UnixNano()) + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", sessionID) + }, 200) + require.Equal(t, 200, code, "a pinned release of a registered family must route; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the dated request must price at the registered rate, not zero") + }) + + t.Run("a route pinned to one dated build refuses another", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-pinned", "anthropic_api", datedA) + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", "") + }, 200) + require.Equal(t, 200, code, "the exact dated id must still route; body: %s", body) + + code, body, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedB, "Reply with exactly: pong", "") + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a provider pinned to one dated build must not serve another; body: %s", body) + }) +} + +// TestBedrockInferenceProfilesReachTheUpstream covers the startup lookup a +// Bedrock client makes. The proxy forwards it to the configured upstream rather +// than denying it, so what comes back is the upstream's answer — never a +// NetBird policy rejection. +func TestBedrockInferenceProfilesReachTheUpstream(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionModelProvider(t, ctx, "infprofiles", "bedrock_api", "anthropic.claude-sonnet-5") + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/inference-profiles", nil) + }, 200) + + assert.Equal(t, 200, code, "the lookup must reach the upstream; body: %s", body) + assert.NotContains(t, body, "llm_policy.", + "the proxy must not answer a control-plane lookup with a policy denial") + assert.Contains(t, body, "inferenceProfileSummaries", + "the upstream's own answer must come back untouched") +} + +// provisionDiscoveryProvider brings up one mock-backed provider enumerating a +// single model, with an allowlist guardrail in effect, plus a connected client. +func provisionDiscoveryProvider(t *testing.T, ctx context.Context) pricedEnv { + t.Helper() + env := provisionModelProvider(t, ctx, "noninference", "openai_api", harness.VLLMModel) + + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-noninference-allowlist-" + fmt.Sprint(time.Now().UnixNano()) + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + _, err = srv.UpdatePolicy(ctx, env.policyID, api.AgentNetworkPolicyRequest{ + Name: "e2e-noninference", + Enabled: &enabled, + SourceGroups: []string{env.groupID}, + DestinationProviderIds: []string{env.providerID}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "attach guardrail to policy") + return env +} + +// provisionModelProvider brings up the mock, one provider under the given +// catalog id enumerating exactly one model, an authorising policy, and a +// connected proxy + client. +func provisionModelProvider(t *testing.T, ctx context.Context, name, catalogID, model string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + suffix := strings.ToLower(name) + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gwr-" + suffix}) + 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-gwr-" + suffix + "-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") + + dummyKey := "sk-gwr-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gwr-" + suffix, + ProviderId: catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: model, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gwr-" + suffix, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gwr-"+suffix, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.URL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} diff --git a/e2e/agentnetwork/main_test.go b/e2e/agentnetwork/main_test.go index cc366b3fb..687af1d4d 100644 --- a/e2e/agentnetwork/main_test.go +++ b/e2e/agentnetwork/main_test.go @@ -54,3 +54,19 @@ func run(m *testing.M) int { return m.Run() } + +// waitBeforeRetry pauses between attempts of a polling loop and reports +// whether the caller should keep going. A cancelled context ends the loop +// where a plain sleep would keep retrying against it: every call fails +// instantly once ctx is done, so the loop would spend its whole remaining +// window sleeping between failures nobody is waiting for any more. +func waitBeforeRetry(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/e2e/agentnetwork/streaming_test.go b/e2e/agentnetwork/streaming_test.go new file mode 100644 index 000000000..a5fa8df3f --- /dev/null +++ b/e2e/agentnetwork/streaming_test.go @@ -0,0 +1,209 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "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" +) + +// streamedModel is priced high enough that a mis-metered request is obvious in +// the recorded cost, and named so it cannot collide with another test's route. +const streamedModel = "e2e-streamed-model" + +const ( + streamInRate = 0.010 + streamOutRate = 0.020 + // The cache-read bucket is priced separately from input, so a run that + // folded the two together fails the per-bucket assertions below. + streamCacheReadRate = 0.001 +) + +// TestStreamingResponseMetersInputTokens is the end-to-end guard for the +// metering bug this endpoint's gateway-protocol work fixed. +// +// On a streamed answer the input-token count exists only in the opening +// message_start event; every later frame reports output. A response read with +// the wrong vendor's parser — the shape a gateway record produces when it names +// one API surface and serves another — never looks at that event, so input +// metered as zero and the bulk of the bill silently vanished. Nothing in the +// suite sent stream: true before this test, so the whole branch went unrun. +// +// The provider points at the mock's streaming listener, which answers every +// request as SSE with token counts that differ from the buffered surface. That +// difference is the point: passing these assertions is only possible if the +// stream accumulator ran. +func TestStreamingResponseMetersInputTokens(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "anthropic_api") + + sessionID := fmt.Sprintf("e2e-session-stream-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat must succeed; body: %s", body) + assert.Contains(t, body, "message_start", + "the client must receive the event stream itself, not a buffered rewrite of it") + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "input tokens live in message_start; zero here is the bug this test exists for") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens ride message_delta and supersede the message_start seed") + assert.Equal(t, harness.VLLMStreamCacheReadTokens, int(row.CachedInputTokens), + "the Anthropic cache bucket rides message_start too, and only its own parser reads it") + + // The Anthropic surface bills cache reads additively, so the input bucket + // prices the full input count rather than a remainder. + wantInput := float64(harness.VLLMStreamInputTokens) / 1000 * streamInRate + wantOutput := float64(harness.VLLMStreamOutputTokens) / 1000 * streamOutRate + wantCacheRead := float64(harness.VLLMStreamCacheReadTokens) / 1000 * streamCacheReadRate + assert.InDelta(t, wantInput, row.InputCostUsd, 1e-6, "input cost must price the streamed input tokens") + assert.InDelta(t, wantOutput, row.OutputCostUsd, 1e-6, "output cost must price the streamed output tokens") + // The total, not merely a positive number: input and output alone are + // positive, so a cache bucket parsed and then never billed would pass any + // weaker assertion. The gap is 7e-6, well outside the delta. + assert.InDelta(t, wantInput+wantOutput+wantCacheRead, row.CostUsd, 1e-6, + "the recorded cost must be every bucket the surface bills, cache reads included") +} + +// TestStreamingOnGatewayTypedProvider drives the same streamed Anthropic call +// through a provider record whose catalog id names the OpenAI surface — the +// exact misconfiguration that hid the bug, since gateway records commonly pin +// one parser while the upstream serves another shape entirely. +// +// The router must choose the parser from the request path rather than the +// record's provider id, or the Anthropic usage block goes unread and input +// meters at zero all over again. +func TestStreamingOnGatewayTypedProvider(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "openai_api") + + sessionID := fmt.Sprintf("e2e-session-stream-gw-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat through a gateway record must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "a record typed openai_api must still read the Anthropic usage block it is actually serving") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens must survive the surface mismatch too") + assert.InDelta(t, float64(harness.VLLMStreamInputTokens)/1000*streamInRate, row.InputCostUsd, 1e-6, + "the request must be priced on the surface it spoke, not the one the record names") +} + +// provisionStreamingProvider brings up the mock, one provider pointed at its +// streaming listener under the given catalog id, a policy authorising it, and a +// connected proxy + client. +func provisionStreamingProvider(t *testing.T, ctx context.Context, catalogID string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + name := "stream-" + catalogID + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-" + 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-" + name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + // Deleting the group does not delete the key it auto-joins, so the key + // needs a cleanup of its own. + t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) }) + require.NotEmpty(t, sk.Key, "setup key plaintext") + + dummyKey := "sk-stream-e2e" + cacheRead := streamCacheReadRate + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: catalogID, + UpstreamUrl: vllm.StreamURL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{{ + Id: streamedModel, + InputPer1k: streamInRate, + OutputPer1k: streamOutRate, + CacheReadPer1k: &cacheRead, + }}, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-" + name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, name, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.StreamURL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} + +// chatStreamUntil drives one streamed chat, retrying to absorb the tunnel and +// DNS jitter a first call through a fresh peer can hit. +func chatStreamUntil(t *testing.T, ctx context.Context, env pricedEnv, kind, model, sessionID string) (int, string) { + t.Helper() + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := env.client.ChatStream(ctx, env.endpoint, env.proxyIP, kind, model, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + if code != 200 { + t.Logf("=== proxy logs ===\n%s", env.proxy.Logs(context.Background())) + } + return code, body +} diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 0d7f016a6..9e9e7b34a 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net/http" "os/exec" "strconv" "strings" @@ -292,6 +293,27 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) } +// ChatStream is Chat with "stream": true in the request body, so the proxy's +// request parser marks the call as streaming and its response parser takes the +// SSE accumulator rather than the buffered-body path. Pair it with a provider +// pointed at VLLM.StreamURL, which answers every request as an event stream. +func (cl *Client) ChatStream(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + var path, body string + var headers []string + switch kind { + case WireMessages: + path = "/v1/messages" + headers = []string{"anthropic-version: 2023-06-01"} + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"stream":true,"messages":[{"role":"user","content":%q}]}`, model, prompt) + default: + path = "/v1/chat/completions" + // include_usage is what makes a real OpenAI stream emit its final usage + // frame; without it the last chunk carries no tokens at all. + body = fmt.Sprintf(`{"model":%q,"stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":%q}]}`, model, prompt) + } + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) +} + // Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike // Chat, the model is carried in the request path (project/region/model), so the // proxy routes by path and mints the service-account OAuth token; the body uses @@ -322,10 +344,29 @@ func withSessionID(headers []string, sessionID string) []string { return append(headers, "x-session-id: "+sessionID) } -// post runs curl in a throwaway container sharing the client's network -// namespace so the request traverses the WireGuard tunnel, pinning the endpoint -// to the proxy IP. It returns the HTTP status and response body. +// Get issues a GET to the agent-network endpoint over the client's tunnel. +// Model discovery and the connection-warming probe are read-only endpoints +// that carry no body, so they can't go through the chat helpers. +func (cl *Client) Get(ctx context.Context, endpoint, proxyIP, path string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodGet, endpoint, proxyIP, path, "", extraHeaders) +} + +// PostJSON issues an arbitrary JSON POST over the client's tunnel, for wire +// shapes the typed helpers don't cover (token counting, say). +func (cl *Client) PostJSON(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// post issues a JSON POST. Retained as the shorthand the chat helpers use. func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// do runs curl in a throwaway container sharing the client's network +// namespace so the request traverses the WireGuard tunnel, pinning the endpoint +// to the proxy IP. It returns the HTTP status and response body. An empty body +// sends no payload, which is what a GET needs. +func (cl *Client) do(ctx context.Context, method, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { url := "https://" + endpoint + path args := []string{ "run", "--rm", @@ -334,13 +375,15 @@ func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string "-sk", "--connect-timeout", "5", "--max-time", "90", "--resolve", endpoint + ":443:" + proxyIP, "-o", "/dev/stderr", "-w", "%{http_code}", - "-X", "POST", url, + "-X", method, url, "-H", "Content-Type: application/json", } for _, h := range extraHeaders { args = append(args, "-H", h) } - args = append(args, "--data", body) + if body != "" { + args = append(args, "--data", body) + } cmd := exec.CommandContext(ctx, "docker", args...) // -w writes the status code to stdout; -o /dev/stderr writes the body to // stderr so we can capture both separately. diff --git a/e2e/harness/vllm.go b/e2e/harness/vllm.go index 2f3d306cc..cf9316325 100644 --- a/e2e/harness/vllm.go +++ b/e2e/harness/vllm.go @@ -18,18 +18,63 @@ const ( vllmImage = "nginx:alpine" vllmAlias = "vllm" vllmPort = "8000/tcp" + // vllmStreamPort serves the same wire shapes as an SSE stream. See the + // nginx config for why streaming lives on its own listener. + vllmStreamPort = "8001/tcp" // VLLMModel is the served model id the mock advertises and echoes back. It // matches a real small model commonly served by vLLM so the provider's // enumerated model and the client's request line up. VLLMModel = "Qwen/Qwen2.5-0.5B-Instruct" + // VLLMUnlistedModel is a second id the mock's model listing advertises but + // no test provider enumerates, so a filtered listing is observably shorter + // than the upstream's own. + VLLMUnlistedModel = "Qwen/Qwen2.5-7B-Instruct" +) + +// Token counts the mock reports per wire shape. Tests assert on these rather +// than on "> 0" so a response parsed with the wrong provider's parser (which +// would read a different field, or none) fails loudly instead of passing on +// a coincidental non-zero. +const ( + // VLLMChatInputTokens / VLLMChatOutputTokens ride the OpenAI usage block. + VLLMChatInputTokens = 11 + VLLMChatOutputTokens = 2 + // VLLMMessagesInputTokens / VLLMMessagesOutputTokens ride the Anthropic + // usage block, whose field names the OpenAI parser cannot read. + VLLMMessagesInputTokens = 17 + VLLMMessagesOutputTokens = 3 +) + +// Token counts the streaming surface reports. They differ from the +// non-streaming ones on purpose: a test that asserts these numbers proves the +// SSE accumulator ran, rather than a buffered JSON body having been parsed. +// +// Input and cache-read arrive on message_start; output arrives on +// message_delta and supersedes the seed value message_start carries. Any +// parser that cannot read message_start reports zero input tokens — which is +// exactly the bug these counts exist to catch. +const ( + VLLMStreamInputTokens = 29 + VLLMStreamOutputTokens = 5 + VLLMStreamCacheReadTokens = 7 ) // vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's -// default: no TLS, port 8000). It answers /v1/models with a one-model list and -// any chat/completions path with a canned OpenAI-shaped chat completion carrying -// a non-zero usage block, so the proxy's OpenAI parser records real token -// consumption. Running actual vLLM in CI is infeasible (GPU + multi-GB model +// default: no TLS, port 8000), and additionally answers the wire shapes the +// other catalog surfaces speak so one mock can stand in for every provider the +// proxy routes to. Running actual vLLM in CI is infeasible (GPU + multi-GB model // download), so this stands in for the wire contract the proxy depends on. +// +// Each shape answers with its own vendor's usage block, so a response parsed +// under the wrong surface meters zero rather than passing by accident: +// +// - /v1/chat/completions (and any unmatched path): OpenAI chat completion. +// - /v1/messages: Anthropic Messages, snake_case usage plus a cache bucket. +// - /model/{id}/invoke: Bedrock InvokeModel, which carries the Anthropic body. +// - the token-counting endpoints: a count, with no usage block at all. +// +// The model listing advertises two models so a policy that authorises one +// produces an observably shorter list than the upstream's own. const vllmNginxConf = `pid /tmp/nginx.pid; events {} http { @@ -37,13 +82,75 @@ http { listen 8000; location = /v1/models { default_type application/json; - return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"}]}'; + return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"},{"id":"Qwen/Qwen2.5-7B-Instruct","object":"model","owned_by":"vllm"}]}'; + } + location = /v1/messages { + default_type application/json; + return 200 '{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-sonnet-5","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location = /v1/messages/count_tokens { + default_type application/json; + return 200 '{"input_tokens":7}'; + } + location ~ ^/model/.+/invoke$ { + default_type application/json; + return 200 '{"id":"msg_e2e_bedrock","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location ~ ^/model/.+/count-tokens$ { + default_type application/json; + return 200 '{"inputTokens":9}'; + } + location = /api/hello { + return 200; + } + location = /inference-profiles { + default_type application/json; + return 200 '{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-sonnet-5","status":"ACTIVE"}]}'; } location / { default_type application/json; return 200 '{"id":"chatcmpl-e2e-vllm","object":"chat.completion","created":1700000000,"model":"Qwen/Qwen2.5-0.5B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2,"total_tokens":13}}'; } } + + # The streaming surface, on its own port so the response content type is a + # property of the listener rather than of a per-request branch: nginx sets + # Content-Type from default_type, which cannot be varied inside an "if", and + # a second Content-Type via add_header would leave the proxy reading the + # wrong one. A provider record pointed at this port streams every answer. + # + # Input and cache-read tokens ride message_start, output rides message_delta + # — the split that makes a stream different from a buffered body, and the + # reason a parser that ignores message_start meters input as zero. + server { + listen 8001; + location = /v1/messages { + default_type text/event-stream; + return 200 'event: message_start +data: {"type":"message_start","message":{"id":"msg_e2e_stream","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":29,"output_tokens":1,"cache_read_input_tokens":7}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}} + +event: message_stop +data: {"type":"message_stop"} + +'; + } + location / { + default_type text/event-stream; + return 200 'data: {"choices":[{"delta":{"content":"pong"}}]} + +data: {"choices":[],"usage":{"prompt_tokens":29,"completion_tokens":5,"total_tokens":34}} + +data: [DONE] + +'; + } + } } ` @@ -55,6 +162,10 @@ type VLLM struct { workDir string // URL is the upstream URL the vllm provider points at (http://:8000). URL string + // StreamURL is the same mock's streaming listener. A provider pointed here + // answers every request as SSE, so the proxy's streaming accumulator runs + // instead of its buffered-body parser. + StreamURL string } // StartVLLM runs the mock vLLM server on the shared network over plain HTTP. @@ -73,14 +184,17 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) { req := testcontainers.ContainerRequest{ Image: vllmImage, - ExposedPorts: []string{vllmPort}, + ExposedPorts: []string{vllmPort, vllmStreamPort}, Networks: []string{c.network.Name}, NetworkAliases: map[string][]string{c.network.Name: {vllmAlias}}, Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"}, HostConfigModifier: func(hc *container.HostConfig) { hc.Binds = append(hc.Binds, workDir+":/conf:ro") }, - WaitingFor: wait.ForListeningPort(vllmPort).WithStartupTimeout(60 * time.Second), + WaitingFor: wait.ForAll( + wait.ForListeningPort(vllmPort), + wait.ForListeningPort(vllmStreamPort), + ).WithStartupTimeout(60 * time.Second), } ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ @@ -92,7 +206,12 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) { return nil, fmt.Errorf("start vllm container: %w", err) } - return &VLLM{container: ctr, workDir: workDir, URL: "http://" + vllmAlias + ":8000"}, nil + return &VLLM{ + container: ctr, + workDir: workDir, + URL: "http://" + vllmAlias + ":8000", + StreamURL: "http://" + vllmAlias + ":8001", + }, nil } // Logs returns the vLLM container logs, for diagnostics on failure. diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index 2c4efd0b4..c534f9a85 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -296,6 +296,8 @@ var providers = []Provider{ // account to be on >= 30-day data retention or all requests // 400. Models: []Model{ + {ID: "claude-opus-5", Label: "Claude Opus 5", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-sonnet-5", Label: "Claude Sonnet 5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, {ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000}, {ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, {ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, @@ -355,6 +357,8 @@ var providers = []Provider{ // Llama 3.3 70B entry kept unchanged — LiteLLM tracks only // per-region Llama 3 entries; standalone 3.3 not yet listed. Models: []Model{ + {ID: "anthropic.claude-opus-5", Label: "Claude Opus 5 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-sonnet-5", Label: "Claude Sonnet 5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, {ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, {ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, {ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, @@ -406,6 +410,8 @@ var providers = []Provider{ // exists — the router denies unmeterable publishers rather than forward // them uncounted. Models: []Model{ + {ID: "claude-opus-5", Label: "Claude Opus 5 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-sonnet-5", Label: "Claude Sonnet 5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, {ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000}, {ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, {ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, diff --git a/management/internals/modules/agentnetwork/catalog/catalog_test.go b/management/internals/modules/agentnetwork/catalog/catalog_test.go new file mode 100644 index 000000000..e4e887e6f --- /dev/null +++ b/management/internals/modules/agentnetwork/catalog/catalog_test.go @@ -0,0 +1,36 @@ +package catalog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClaudeLineupSelectable pins the models Claude Code resolves to by +// default. A model absent from the lineup can't be ticked on a provider +// record, so llm_router denies it as not-routable and the operator has no +// way to authorise the client's own default. +func TestClaudeLineupSelectable(t *testing.T) { + for providerID, wanted := range map[string][]string{ + "anthropic_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"}, + "bedrock_api": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5", "anthropic.claude-haiku-4-5"}, + "vertex_ai_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"}, + } { + provider, ok := Lookup(providerID) + require.True(t, ok, "catalog must define %s", providerID) + + selectable := make(map[string]Model, len(provider.Models)) + for _, m := range provider.Models { + selectable[m.ID] = m + } + for _, id := range wanted { + model, found := selectable[id] + require.True(t, found, "%s must offer %s", providerID, id) + assert.NotEmpty(t, model.Label, "%s/%s needs a label for the picker", providerID, id) + assert.Positive(t, model.InputPer1k, "%s/%s needs an input rate", providerID, id) + assert.Positive(t, model.OutputPer1k, "%s/%s needs an output rate", providerID, id) + assert.Positive(t, model.ContextWindow, "%s/%s needs a context window", providerID, id) + } + } +} diff --git a/management/internals/modules/agentnetwork/pricing/defaults.go b/management/internals/modules/agentnetwork/pricing/defaults.go index c690313bc..315cfe208 100644 --- a/management/internals/modules/agentnetwork/pricing/defaults.go +++ b/management/internals/modules/agentnetwork/pricing/defaults.go @@ -47,17 +47,11 @@ var supplementalDefaults = map[string]map[string]Entry{ "gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005}, }, "anthropic": { - // claude-opus-5 is not yet in the catalog lineup but gateway / - // grandfathered traffic uses it; priced so it isn't skipped. - "claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625}, // "kimi-k3[1m]" is the 1M-context alias some Claude Code guides // configure against Moonshot's Anthropic-compatible endpoint; // priced identically to kimi-k3 so those requests aren't skipped. "kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003}, }, - "bedrock": { - "anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625}, - }, } var ( diff --git a/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml b/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml index bb1cb09a8..78830ae3c 100644 --- a/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml +++ b/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml @@ -82,6 +82,11 @@ anthropic: output_per_1k: 0.015 cache_read_per_1k: 0.0003 cache_creation_per_1k: 0.00375 + claude-sonnet-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 kimi-k3: input_per_1k: 0.003 output_per_1k: 0.015 @@ -145,6 +150,11 @@ bedrock: output_per_1k: 0.015 cache_read_per_1k: 0.0003 cache_creation_per_1k: 0.00375 + anthropic.claude-sonnet-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 meta.llama3-3-70b-instruct: input_per_1k: 0.00072 output_per_1k: 0.00072 diff --git a/management/internals/modules/agentnetwork/pricing/defaults_test.go b/management/internals/modules/agentnetwork/pricing/defaults_test.go index 99c965687..04b6de550 100644 --- a/management/internals/modules/agentnetwork/pricing/defaults_test.go +++ b/management/internals/modules/agentnetwork/pricing/defaults_test.go @@ -116,11 +116,13 @@ func TestDefaultTable_PinnedRates(t *testing.T) { assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input") assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation") - // Supplementals present on their surfaces. + // Every id below must stay priced whichever source provides it: the + // catalog lineup for the current Claude 5 family, supplementalDefaults + // for the ids the dashboard deliberately doesn't offer. for surface, ids := range map[string][]string{ "openai": {"gpt-5", "gpt-5-mini", "gpt-5-nano"}, - "anthropic": {"claude-opus-5", "kimi-k3[1m]", "kimi-k3"}, - "bedrock": {"anthropic.claude-opus-5"}, + "anthropic": {"claude-opus-5", "claude-sonnet-5", "kimi-k3[1m]", "kimi-k3"}, + "bedrock": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5"}, } { for _, id := range ids { _, ok := table[surface][id] diff --git a/proxy/internal/llm/model.go b/proxy/internal/llm/model.go index 76ccfeccf..2e056a57a 100644 --- a/proxy/internal/llm/model.go +++ b/proxy/internal/llm/model.go @@ -13,6 +13,14 @@ func NormalizeBedrockModel(modelID string) string { return sharedllm.NormalizeBedrockModel(modelID) } +// NormalizeAnthropicModel strips the trailing "-YYYYMMDD" release-date suffix +// from an Anthropic model id so a dated id a client pins matches the undated +// one the operator registered. Thin delegate to shared/llm for the same +// contract reason as the two below. +func NormalizeAnthropicModel(modelID string) string { + return sharedllm.NormalizeAnthropicModel(modelID) +} + // NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id // so it matches the catalog/pricing key. Thin delegate to shared/llm, kept // beside NormalizeBedrockModel for the same contract reason. diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go index ce6e636cf..52cedb60e 100644 --- a/proxy/internal/llm/pricing/pricing.go +++ b/proxy/internal/llm/pricing/pricing.go @@ -10,6 +10,8 @@ package pricing import ( "fmt" "math" + + sharedllm "github.com/netbirdio/netbird/shared/llm" ) // Entry is a single model's input and output pricing, expressed in USD per @@ -92,7 +94,10 @@ func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) { return &Table{entries: entries}, nil } -// Lookup returns the entry for the given provider surface and model. +// Lookup returns the entry for the given provider surface and model. A +// dated Anthropic id falls back to its undated form, so a client pinning +// "claude-sonnet-4-5-20250929" bills at the registered "claude-sonnet-4-5" +// rate instead of recording no cost at all. func (t *Table) Lookup(provider, model string) (Entry, bool) { if t == nil { return Entry{}, false @@ -101,7 +106,14 @@ func (t *Table) Lookup(provider, model string) (Entry, bool) { if !ok { return Entry{}, false } - e, ok := byModel[model] + if e, found := byModel[model]; found { + return e, true + } + undated := sharedllm.NormalizeAnthropicModel(model) + if undated == model { + return Entry{}, false + } + e, ok := byModel[undated] return e, ok } diff --git a/proxy/internal/llm/pricing/pricing_test.go b/proxy/internal/llm/pricing/pricing_test.go index b946faa7f..e7d339f06 100644 --- a/proxy/internal/llm/pricing/pricing_test.go +++ b/proxy/internal/llm/pricing/pricing_test.go @@ -175,3 +175,22 @@ func TestNewTable_NilAndEmpty(t *testing.T) { require.NoError(t, err) assert.Empty(t, entries, "nil in, empty (never-matching) map out for the per-record map") } + +// TestLookup_DatedAnthropicIDFallsBackToUndated covers a client pinning a +// release date on a model priced under its undated id. Without the +// fallback the request records no cost at all. +func TestLookup_DatedAnthropicIDFallsBackToUndated(t *testing.T) { + table, err := NewTable(map[string]map[string]EntryJSON{ + "anthropic": { + "claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015}, + }, + }) + require.NoError(t, err, "table must build from a valid defaults map") + + entry, ok := table.Lookup("anthropic", "claude-sonnet-4-5-20250929") + require.True(t, ok, "a dated id must resolve to the undated entry") + assert.InDelta(t, 0.003, entry.InputPer1K, 1e-9, "dated id must bill at the registered rate") + + _, ok = table.Lookup("anthropic", "claude-sonnet-9-9-20250929") + assert.False(t, ok, "an unknown family must stay unpriced") +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go index 2ce706cda..8e2e0590c 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go @@ -11,6 +11,7 @@ import ( "fmt" "strconv" + "github.com/netbirdio/netbird/proxy/internal/llm" "github.com/netbirdio/netbird/proxy/internal/llm/pricing" "github.com/netbirdio/netbird/proxy/internal/middleware" ) @@ -175,13 +176,28 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar // Anthropic route still bills its cache buckets additively. func (m *Middleware) lookupCosts(md []middleware.KV, surface, model string, inTokens, outTokens, cachedTokens, cacheCreationTokens int64) (pricing.Costs, bool) { if recordID := lookupKV(md, middleware.KeyLLMResolvedProviderID); recordID != "" { - if entry, ok := m.perRecord[recordID][model]; ok { + if entry, ok := perRecordEntry(m.perRecord[recordID], model); ok { return pricing.EntryCosts(entry, surface, inTokens, outTokens, cachedTokens, cacheCreationTokens), true } } return m.defaults.Costs(surface, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) } +// perRecordEntry resolves the operator's stored price for a model on one +// provider record, falling back to the undated form of a dated Anthropic id +// so a client that pins a release date still bills at the registered rate. +func perRecordEntry(byModel map[string]pricing.Entry, model string) (pricing.Entry, bool) { + if entry, ok := byModel[model]; ok { + return entry, true + } + undated := llm.NormalizeAnthropicModel(model) + if undated == model { + return pricing.Entry{}, false + } + entry, ok := byModel[undated] + return entry, ok +} + // usd renders a cost as the fixed-precision string every cost.usd_* key // carries, so the per-bucket values and the aggregates round identically. // diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go index 1863aff20..d2b14f265 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -84,8 +84,10 @@ func (m *Middleware) MutationsSupported() bool { return false } 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) + surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) + nonInference, _ := lookupMetadata(in.Metadata, middleware.KeyLLMNonInference) - if denial := m.evaluateAllowlist(providerID, model, modelPresent); denial != nil { + if denial := m.evaluateAllowlist(providerID, surface, model, modelPresent, nonInference == "true"); denial != nil { return denial, nil } @@ -114,7 +116,7 @@ func (m *Middleware) Close() error { return nil } // 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 { +func (m *Middleware) evaluateAllowlist(providerID, surface, model string, modelPresent, nonInference bool) *middleware.Output { if len(m.cfg.ProviderAllowlists) == 0 { return nil } @@ -122,7 +124,7 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo // 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) + return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } allowlist, restricted := m.cfg.ProviderAllowlists[providerID] if !restricted { @@ -133,18 +135,29 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo // 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. + // + // The exception is a non-inference endpoint the router already authorised. + // The model listing and the connection-warming probe name no model + // anywhere — not in a body, not in the path — so failing closed here + // rejected model discovery for exactly the accounts that configured an + // allowlist, which is the outage this endpoint is meant to avoid. The + // per-model lookup does name one (the router stamps it from the path), so + // it still falls through to the allowlist check below. if !modelPresent || normaliseModel(model) == "" { - return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) + if nonInference { + return nil + } + return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } if modelInAllowlist(allowlist, model) { return nil } - return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel) + return denyModel(surface, model, denyCodeModel, denyMessageModel, denyReasonModel) } // denyModel builds a 403 deny Output for a model-allowlist rejection. model is // included in the details only when non-empty. -func denyModel(model, code, message, reason string) *middleware.Output { +func denyModel(surface, model, code, message, reason string) *middleware.Output { details := map[string]string{} if model != "" { details["model"] = model @@ -156,6 +169,7 @@ func denyModel(model, code, message, reason string) *middleware.Output { Code: code, Message: message, Details: details, + Surface: surface, }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go index 5f35fefd3..19d8473fe 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go @@ -343,3 +343,52 @@ func TestFactoryNormalisesAllowlist(t *testing.T) { require.NoError(t, err) assert.Equal(t, middleware.DecisionAllow, out2.Decision, "trimmed entry must still match") } + +// TestAllowlistSkipsNonInferenceWithoutModel covers the reported regression: +// GET /v1/models carries no model anywhere, so the fail-closed rule above +// denied model discovery for exactly the accounts that configured a provider +// allowlist — the clients that read a 403 here render an empty model picker. +// The router authorises those endpoints by path before the guardrail sees +// them, so an absent model there is expected rather than undeterminable. +func TestAllowlistSkipsNonInferenceWithoutModel(t *testing.T) { + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "model discovery must not be refused because it names no model") +} + +// TestAllowlistStillAppliesToNonInferenceWithModel pins that the exemption is +// scoped to requests that genuinely name nothing. The per-model lookup +// (GET /v1/models/{id}) is non-inference too, but the router stamps the model +// from its path, so the allowlist must still decide it — otherwise the +// exemption becomes a way to confirm a model the policy blocks. +func TestAllowlistStillAppliesToNonInferenceWithModel(t *testing.T) { + mw := New(providerCfg("gpt-4o")) + + t.Run("model in the allowlist", func(t *testing.T) { + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"}, + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "an allowlisted model must stay reachable") + }) + + t.Run("model outside the allowlist", func(t *testing.T) { + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"}, + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-5"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "non-inference must not become a way past the allowlist") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, + "a named but blocked model is blocked, not unknown") + }) +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go index 722588a15..60b99e194 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go @@ -217,6 +217,32 @@ func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mut return mutations } +// bodyInjectableSurfaces are the request-body dialects that accept the +// OpenAI-standard identity fields this middleware writes. A surface +// outside this set gets header-only stamping: "user" and "metadata.tags" +// are not part of the Anthropic Messages schema, which rejects unknown +// top-level fields and permits only "user_id" under metadata, so writing +// them into an Anthropic-shaped body turns a working request into a 400. +// Claude Code speaks that shape through gateway records pinned to the +// OpenAI parser, so the check keys on the detected surface rather than +// on the provider record. +var bodyInjectableSurfaces = map[string]struct{}{ + "openai": {}, + // An empty surface means no parser claimed the path (a custom gateway + // base). Those upstreams are OpenAI-compatible by convention, so keep + // the long-standing behaviour rather than silently dropping identity. + "": {}, +} + +// bodyAcceptsOpenAIIdentity reports whether the request body may carry the +// OpenAI-standard identity fields, read from the surface llm_request_parser +// resolved from the request path. +func bodyAcceptsOpenAIIdentity(in *middleware.Input) bool { + surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) + _, ok := bodyInjectableSurfaces[surface] + return ok +} + // injectIntoBody parses the request body and writes the supplied // identity dimensions into it. Tags land at metadata.tags (creating // the metadata object when absent); the user identity lands at the @@ -225,6 +251,8 @@ func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mut // was written. Returns ok=false (no mutation) when: // // - both inputs are empty (nothing to write); +// - the body speaks a dialect without these fields (see +// bodyInjectableSurfaces); // - the body is empty or truncated (we don't have the full document // to safely round-trip); // - the body isn't a JSON object (skip silently — this middleware @@ -245,6 +273,9 @@ func injectIntoBody(in *middleware.Input, tags []string, userID string) ([]byte, if in == nil || len(in.Body) == 0 || in.BodyTruncated { return nil, false } + if !bodyAcceptsOpenAIIdentity(in) { + return nil, false + } var doc map[string]any if err := json.Unmarshal(in.Body, &doc); err != nil { return nil, false diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go index 8ec0930b5..f602f5c33 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go @@ -704,3 +704,57 @@ func TestInject_ExtraHeaders_EmptyValueSkipped(t *testing.T) { "empty extra value must not be stamped") } } + +// TestInject_AnthropicBodyIsNotRewritten pins the shape gate. Claude Code +// reaches a LiteLLM record on /v1/messages, where "user" is not a +// permitted top-level field and metadata accepts only "user_id", so +// writing the OpenAI-standard fields would turn a working request into a +// 400 naming a field the client never sent. Header stamping still runs, so +// spend tracking and per-end-user budgets keep working. +func TestInject_AnthropicBodyIsNotRewritten(t *testing.T) { + rule := liteLLMRuleWithBody() + rule.HeaderPair.EndUserIDInBody = true + mw := New(Config{Providers: []ProviderInjection{rule}}) + + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + in.Body = []byte(`{"model":"claude-sonnet-5","messages":[]}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + assert.Empty(t, out.Mutations.BodyReplace, + "an Anthropic-shaped body must reach the upstream unmodified") + + var endUser string + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-end-user-id" { + endUser = kv.Value + } + } + assert.Equal(t, "alice@example.com", endUser, + "header stamping must still carry identity when body inject is skipped") +} + +// TestInject_OpenAIBodyStillRewritten guards the gate against +// over-reaching: the OpenAI surface must keep its body-level identity, +// which is the only path LiteLLM's tag-budget check reads. +func TestInject_OpenAIBodyStillRewritten(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "openai"}) + in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.NotEmpty(t, out.Mutations.BodyReplace, "the OpenAI surface still gets body tags") + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + meta, ok := doc["metadata"].(map[string]any) + require.True(t, ok, "metadata must be an object") + assert.NotEmpty(t, meta["tags"], "metadata.tags must still be written") +} diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go index 42ac56b9b..1e7edcf42 100644 --- a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go @@ -84,6 +84,15 @@ func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middlew return allowNoAttribution(), nil } + // Model-listing and other non-inference endpoints carry no model, and + // management's per-model allowlist fails closed on an empty one. The + // router has already authorised the route against the caller's groups + // and the request consumes no tokens, so gating it on a model that + // cannot exist would only break gateway model discovery. + if lookupKV(in.Metadata, middleware.KeyLLMNonInference) == "true" { + return allowNoAttribution(), nil + } + providerID := lookupKV(in.Metadata, middleware.KeyLLMResolvedProviderID) if providerID == "" { // llm_router didn't emit a resolved provider id — usually @@ -117,7 +126,7 @@ func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middlew } if resp.GetDecision() == "deny" { - return denyFromManagement(resp), nil + return denyFromManagement(resp, lookupKV(in.Metadata, middleware.KeyLLMProvider)), nil } return allowFromManagement(resp), nil } @@ -161,7 +170,7 @@ func allowFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.O // envelope. The deny code surfaces verbatim through the framework's // fixed JSON template; arbitrary middleware bytes can't reach the // wire. -func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output { +func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse, surface string) *middleware.Output { code := resp.GetDenyCode() if code == "" { code = "llm_policy.cap_exceeded" @@ -176,6 +185,7 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou DenyReason: &middleware.DenyReason{ Code: code, Message: denyMessageForCode(code), + Surface: surface, }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, 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 87aa8e9e9..7754998ee 100644 --- a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go @@ -224,3 +224,35 @@ func TestMetadataKeys_Allowlist(t *testing.T) { } assert.ElementsMatch(t, want, keys) } + +// TestInvoke_NonInferenceSkipsPreflight covers gateway model discovery: +// GET /v1/models carries no model, and management's per-model allowlist +// fails closed on an empty one, so a pre-flight would deny discovery for +// exactly the accounts that use the model allowlist. The router marks the +// request non-inference after authorising the route, and the gate must +// then allow without calling management at all. +func TestInvoke_NonInferenceSkipsPreflight(t *testing.T) { + mgmt := &fakeMgmt{ + checkResp: &proto.CheckLLMPolicyLimitsResponse{ + Decision: "deny", + DenyCode: "llm_policy.model_blocked", + }, + } + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}, + {Key: middleware.KeyLLMNonInference, Value: "true"}, + }, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision, "model-less endpoints must not be gated on a model") + assert.Nil(t, mgmt.checkReq, "no pre-flight may be sent for a non-inference request") + + assert.Empty(t, lookupKV(out.Metadata, middleware.KeyLLMSelectedPolicyID), + "no policy is attributed when nothing was metered") +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go index d8cd81437..82f44cb50 100644 --- a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go +++ b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go @@ -1,9 +1,13 @@ package llm_request_parser import ( + "context" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" ) func TestParseBedrockPath(t *testing.T) { @@ -36,3 +40,25 @@ func TestParseBedrockPath(t *testing.T) { } } } + +// TestInvoke_BedrockCountTokens covers the dedicated token-counting +// endpoint. Denying it does not break the client, it just pushes context +// counting back onto the inference endpoint, which is billable. +func TestInvoke_BedrockCountTokens(t *testing.T) { + mw := newMiddleware(t) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/model/us.anthropic.claude-sonnet-4-5-20250929-v1:0/count-tokens", + Body: []byte(`{"input":{"converse":{"messages":[]}}}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + + model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel) + require.True(t, ok, "count-tokens carries a model in the path and must emit it") + assert.Equal(t, "anthropic.claude-sonnet-4-5", model, "model must be normalized like any other action") + + stream, _ := metaValue(t, out.Metadata, middleware.KeyLLMStream) + assert.Equal(t, "false", stream, "count-tokens never streams") +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go index b4d1e16d4..7129c2298 100644 --- a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go +++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go @@ -61,6 +61,8 @@ func (middlewareImpl) MetadataKeys() []string { middleware.KeyLLMRequestPromptRaw, middleware.KeyLLMCaptureTruncated, middleware.KeyLLMSessionID, + middleware.KeyLLMAgentID, + middleware.KeyLLMParentAgentID, } } @@ -72,9 +74,9 @@ func (middlewareImpl) Close() error { return nil } // Invoke detects the LLM provider, parses request facts, and emits // metadata. Always returns DecisionAllow; never errors. Provider -// selection prefers the configured providerID (synthesiser-stamped on -// agent-network targets) so requests routed to a custom upstream URL -// still resolve. Falls back to URL sniffing when no providerID is set. +// selection prefers the request path, falling back to the configured +// providerID (synthesiser-stamped on agent-network targets) so requests +// routed to a custom upstream URL still resolve. func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { out := &middleware.Output{Decision: middleware.DecisionAllow} if in == nil { @@ -92,9 +94,14 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle return m.invokeBedrock(in, br), nil } - parser, ok := llm.ParserByName(m.providerID) + // A path that names an API surface wins over the configured providerID: + // a gateway record pinned to "openai" still serves Claude Code on + // /v1/messages, and reading that body with the OpenAI parser loses the + // Anthropic usage block and prices the request on the wrong surface. + // providerID stays the fallback for upstreams whose path says nothing. + parser, ok := llm.DetectParser(extractPath(in.URL)) if !ok { - parser, ok = llm.DetectParser(extractPath(in.URL)) + parser, ok = llm.ParserByName(m.providerID) } if !ok { return out, nil @@ -116,9 +123,9 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle } appendSessionID := func(md []middleware.KV) []middleware.KV { if sessionID != "" { - return append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) + md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) } - return md + return appendAgentIDs(md, in.Headers) } facts, err := parser.ParseRequest(in.Body) @@ -160,6 +167,41 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle return out, nil } +// agentIDHeader and parentAgentIDHeader carry sub-agent attribution: a +// coding agent that spawns helpers stamps the spawned agent's id, plus the +// spawning agent's when that helper is itself nested. Both are opaque +// identifiers rather than content, so they're emitted regardless of the +// prompt-collection toggle, the same way the session id is. +const ( + agentIDHeader = "x-claude-code-agent-id" + parentAgentIDHeader = "x-claude-code-parent-agent-id" +) + +// appendAgentIDs stamps the sub-agent attribution headers onto the metadata +// bag, skipping either one the request doesn't carry. +func appendAgentIDs(md []middleware.KV, headers []middleware.KV) []middleware.KV { + for _, pair := range []struct{ key, header string }{ + {middleware.KeyLLMAgentID, agentIDHeader}, + {middleware.KeyLLMParentAgentID, parentAgentIDHeader}, + } { + if v := headerValue(headers, pair.header); v != "" { + md = append(md, middleware.KV{Key: pair.key, Value: v}) + } + } + return md +} + +// headerValue returns the first non-empty value for the named header. +// Headers arrive in canonical form, so the match is case-insensitive. +func headerValue(headers []middleware.KV, want string) string { + for _, kv := range headers { + if strings.EqualFold(kv.Key, want) && kv.Value != "" { + return kv.Value + } + } + return "" +} + // sessionIDHeaders are request header names that may carry a client // session identifier, checked in order, case-insensitively. Matching is // against Go's canonical header form, so use the hyphenated names the @@ -173,10 +215,8 @@ var sessionIDHeaders = []string{"x-claude-code-session-id", "session-id", "x-ses // canonical form, so the match is case-insensitive. func sessionIDFromHeaders(headers []middleware.KV) string { for _, want := range sessionIDHeaders { - for _, kv := range headers { - if strings.EqualFold(kv.Key, want) && kv.Value != "" { - return kv.Value - } + if v := headerValue(headers, want); v != "" { + return v } } return "" @@ -252,6 +292,12 @@ func parseVertexPath(reqPath string) (vertexRequest, bool) { if c := strings.LastIndex(rest, ":"); c >= 0 { model, action = rest[:c], rest[c+1:] } + // Token counting hangs off the model as its own path segment + // (".../models/{model}/count-tokens:rawPredict"), so anything past the + // first "/" belongs to the method rather than the model id. + if slash := strings.Index(model, "/"); slash >= 0 { + model = model[:slash] + } model = llm.NormalizeVertexModel(model) if model == "" { return vertexRequest{}, false @@ -298,6 +344,7 @@ func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *mi if sessionID != "" { md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) } + md = appendAgentIDs(md, in.Headers) promptTruncated := false if parser != nil && m.capturePrompt { @@ -345,7 +392,9 @@ func trimBedrockNamespace(reqPath string) string { // // /model/{modelId}/{action} // -// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream}. +// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream, +// count-tokens}. Token counting carries a model and no usage, so it routes +// like any other action and meters to zero. // The modelId may be URL-encoded and may carry a cross-region inference-profile // prefix and a version suffix; normalizeBedrockModel strips both so the model // matches catalog pricing. @@ -369,7 +418,7 @@ func parseBedrockPath(reqPath string) (bedrockRequest, bool) { return bedrockRequest{}, false } switch action { - case "invoke", "converse": + case "invoke", "converse", "count-tokens": return bedrockRequest{model: model}, true case "invoke-with-response-stream", "converse-stream": return bedrockRequest{model: model, stream: true}, true @@ -397,6 +446,7 @@ func (m middlewareImpl) invokeBedrock(in *middleware.Input, br bedrockRequest) * if sessionID != "" { md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) } + md = appendAgentIDs(md, in.Headers) promptTruncated := false if parser != nil && m.capturePrompt { diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go index bc185b295..8d8517860 100644 --- a/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go @@ -45,6 +45,8 @@ func TestMiddleware_StaticSurface(t *testing.T) { middleware.KeyLLMRequestPromptRaw, middleware.KeyLLMCaptureTruncated, middleware.KeyLLMSessionID, + middleware.KeyLLMAgentID, + middleware.KeyLLMParentAgentID, } assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") } @@ -230,6 +232,31 @@ func TestInvoke_ProviderIDConfigBypassesURLSniff(t *testing.T) { assert.Equal(t, "gpt-4o-mini", model) } +func TestInvoke_PathSurfaceBeatsProviderIDConfig(t *testing.T) { + // Gateway records (LiteLLM, Portkey, OpenRouter) pin provider_id + // "openai", but the same record serves Claude Code on /v1/messages. + // Parsing that body as OpenAI reads no usage off the Anthropic + // response and prices the request on a surface where no claude-* + // model exists, so the path has to win. + mw, err := Factory{}.New([]byte(`{"provider_id":"openai"}`)) + require.NoError(t, err, "factory must accept provider_id config") + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: []byte(`{"model":"claude-sonnet-5","stream":true,"messages":[{"role":"user","content":"Hi"}]}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider must be emitted") + assert.Equal(t, "anthropic", provider, "the /v1/messages path selects the Anthropic surface") + + model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel) + require.True(t, ok, "model must be extracted") + assert.Equal(t, "claude-sonnet-5", model) +} + func TestInvoke_UnknownProviderIDFallsBackToURL(t *testing.T) { mw, err := Factory{}.New([]byte(`{"provider_id":"not-a-real-parser"}`)) require.NoError(t, err, "factory must accept any provider_id string") @@ -416,3 +443,81 @@ func TestInvoke_NilInputAllows(t *testing.T) { assert.Equal(t, middleware.DecisionAllow, out.Decision, "nil input still allows") assert.Empty(t, out.Metadata, "nil input emits no metadata") } + +// TestParseVertexPath_CountTokensKeepsModel covers Vertex token counting, +// where the method hangs off the model as its own path segment. Splitting +// only on the final colon swallowed "/count-tokens" into the model id, so +// the router saw a model no route could claim. +func TestParseVertexPath_CountTokensKeepsModel(t *testing.T) { + cases := map[string]struct { + model string + stream bool + }{ + "/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5:rawPredict": {model: "claude-sonnet-5"}, + "/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5:streamRawPredict": {model: "claude-sonnet-5", stream: true}, + "/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5/count-tokens:rawPredict": {model: "claude-sonnet-5"}, + "/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5@20250929/count-tokens:rawPredict": {model: "claude-sonnet-5"}, + } + for path, want := range cases { + vx, ok := parseVertexPath(path) + require.True(t, ok, "must parse %q", path) + assert.Equal(t, want.model, vx.model, "model for %q", path) + assert.Equal(t, want.stream, vx.stream, "stream flag for %q", path) + assert.Equal(t, "anthropic", vx.publisher, "publisher for %q", path) + } +} + +// TestInvoke_EmitsAgentIDs covers sub-agent attribution: several agents run +// in parallel inside one session, and without their ids every request in +// the session attributes to the session alone. +func TestInvoke_EmitsAgentIDs(t *testing.T) { + mw := newMiddleware(t) + + t.Run("spawned agent", func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`), + Headers: []middleware.KV{ + {Key: "X-Claude-Code-Session-Id", Value: "sess-1"}, + {Key: "X-Claude-Code-Agent-Id", Value: "agent-7"}, + }, + }) + require.NoError(t, err) + + agent, ok := metaValue(t, out.Metadata, middleware.KeyLLMAgentID) + require.True(t, ok, "the spawned agent's id must be emitted") + assert.Equal(t, "agent-7", agent) + + _, ok = metaValue(t, out.Metadata, middleware.KeyLLMParentAgentID) + assert.False(t, ok, "a top-level agent has no parent to emit") + }) + + t.Run("nested agent", func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`), + Headers: []middleware.KV{ + {Key: "X-Claude-Code-Agent-Id", Value: "agent-9"}, + {Key: "X-Claude-Code-Parent-Agent-Id", Value: "agent-7"}, + }, + }) + require.NoError(t, err) + + agent, _ := metaValue(t, out.Metadata, middleware.KeyLLMAgentID) + assert.Equal(t, "agent-9", agent) + parent, ok := metaValue(t, out.Metadata, middleware.KeyLLMParentAgentID) + require.True(t, ok, "a nested agent must carry the spawning agent's id") + assert.Equal(t, "agent-7", parent) + }) + + t.Run("absent on a plain request", func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`), + }) + require.NoError(t, err) + + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMAgentID) + assert.False(t, ok, "no key is emitted when the client sends no agent id") + }) +} diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go index 40cbcb6bd..badd358c5 100644 --- a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go +++ b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go @@ -1,9 +1,13 @@ package llm_router import ( + "context" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" ) // TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native @@ -28,3 +32,86 @@ func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) { assert.False(t, routeClaimsModel(openai, "us.gpt-4o"), "non-Bedrock routes must not strip a us. prefix") } + +// TestRouter_BedrockCountTokensRoutes pins that the token-counting action +// reaches the Bedrock route instead of denying as not-routable. +func TestRouter_BedrockCountTokensRoutes(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "bedrock-prod", + Bedrock: true, + Models: []string{"anthropic.claude-sonnet-4-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + }}}) + + in := newInputWithModelAndURL("anthropic.claude-sonnet-4-5", + "/model/anthropic.claude-sonnet-4-5-20250929-v1:0/count-tokens") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "bedrock"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "count-tokens must route, not deny") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host) +} + +// TestRouter_BedrockInferenceProfilesRoutes covers the startup lookups a +// client makes to resolve a configured inference profile. They carry no +// model, so before they were recognised they denied and wrote a policy +// rejection into the access log on every session start. +func TestRouter_BedrockInferenceProfilesRoutes(t *testing.T) { + bedrock := ProviderRoute{ + ID: "bedrock-prod", + Bedrock: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + } + openai := ProviderRoute{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + mw := New(Config{Providers: []ProviderRoute{openai, bedrock}}) + + for _, path := range []string{ + "/inference-profiles?type=SYSTEM_DEFINED", + "/inference-profiles/us.anthropic.claude-sonnet-5", + } { + out, err := mw.Invoke(context.Background(), newModellessInput(path)) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "%s must route", path) + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host, + "%s must reach the Bedrock provider, not the first authorised one", path) + + nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference) + assert.Equal(t, "true", nonInference, "%s carries no model to gate on", path) + } +} + +// TestRouter_BedrockNamespacedInferenceProfilesStripsPrefix pins that the +// optional gateway namespace is removed before the request goes upstream. +func TestRouter_BedrockNamespacedInferenceProfilesStripsPrefix(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "bedrock-prod", + Bedrock: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + }}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/bedrock/inference-profiles")) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "/bedrock", out.Mutations.RewriteUpstream.StripPathPrefix, + "the namespace prefix must not reach the real Bedrock endpoint") +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 2d987eef6..e6ad332fc 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -109,6 +109,10 @@ func (m *Middleware) MetadataKeys() []string { middleware.KeyLLMAuthorisingGroups, middleware.KeyLLMPolicyDecision, middleware.KeyLLMPolicyReason, + middleware.KeyLLMNonInference, + // Emitted only for the per-model lookup, whose model lives in the path + // rather than a body the parser could read. + middleware.KeyLLMModel, } } @@ -137,29 +141,26 @@ const ( // known to a provider that no policy authorises for the caller deny // with no_authorised_provider. func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + reqPath := requestPath(in.URL) + // The caller's API dialect, used to mirror a denial in the vendor's own + // error shape so the client can explain it to the user. + surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) + model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + // Vertex AI carries the model in the URL path, not the body, and is // selected by path rather than by the model/vendor table. Route it before // the model lookup so a model the parser extracted from the path can't be // claimed by a same-vendor direct provider (e.g. claude-* on api.anthropic.com). - reqPath := requestPath(in.URL) if isVertexPath(reqPath) { - model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel) // The request parser emits no llm.provider for a Vertex publisher it // can't parse (e.g. google/gemini). Forwarding such a request would // bypass token/budget metering, so deny it rather than serve it // unmetered. - if vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider); vendor == "" { - return denyUnmeterable(), nil + if surface == "" { + return denyUnmeterable(surface), nil } route, outcome := m.matchVertex(reqPath, model, in.UserGroups) - switch outcome { - case matchOutcomeFound: - return m.allowWithRoute(route, in.UserGroups), nil - case matchOutcomeUnauthorised: - return denyNoAuthorisedRoute(model), nil - default: - return denyUnknownModel(model), nil - } + return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil } // Bedrock likewise carries the model in the URL path (/model/{id}/{action}), @@ -167,52 +168,120 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar // before the model lookup; when the prefix is present, strip it from the // forwarded path so the real Bedrock endpoint receives its native path. if isBedrockPath(reqPath) { - model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel) native, hadPrefix := splitBedrockNamespace(reqPath) route, outcome := m.matchBedrock(native, model, in.UserGroups) - switch outcome { - case matchOutcomeFound: - out := m.allowWithRoute(route, in.UserGroups) - if hadPrefix && out.Mutations != nil && out.Mutations.RewriteUpstream != nil { - out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix + return m.decide(route, outcome, surface, model, in.UserGroups, func(out *middleware.Output) { + if hadPrefix { + stripBedrockNamespace(out) } - return out, nil - case matchOutcomeUnauthorised: - return denyNoAuthorisedRoute(model), nil - default: - return denyUnknownModel(model), nil - } + }), nil } - model, ok := lookupMetadata(in.Metadata, middleware.KeyLLMModel) - if !ok || model == "" { - // Non-inference endpoints (model listing) carry no model but still - // need rewriting from the synth placeholder to a real upstream; - // clients such as Codex call GET /v1/models at startup to enumerate - // availability and read a 403 as "model unavailable". - route, outcome := m.matchModelless(requestPath(in.URL), in.UserGroups) - switch outcome { - case matchOutcomeFound: - return m.allowWithRoute(route, in.UserGroups), nil - case matchOutcomeUnauthorised: - // A recognised model-less endpoint exists but no provider - // authorises the caller — deny as an authorisation failure - // rather than masking it as a missing model. - return denyNoAuthorisedRoute(model), nil - default: - return denyMissingModel(), nil - } + // GET /v1/models/{id} carries no body, so no model reaches the router in + // metadata — but the path names one, and answering it confirms a model + // exists and is reachable. Authorise it against the model table like any + // other per-model request, then mark it non-inference so it still skips + // the token pre-flight it would otherwise charge nothing against. + if detail, isDetail := modelDetailID(reqPath); isDetail && isNonInferenceMethod(in.Method) { + route, outcome := m.matchRoute(detail, surface, reqPath, in.UserGroups) + return m.decide(route, outcome, surface, detail, in.UserGroups, func(out *middleware.Output) { + markNonInference(out) + // The parser reads models from JSON bodies only, and this request + // has none, so stamp the one the path names. Without it the + // guardrail's own allowlist — a separate, possibly narrower list + // than the route's — never sees a model to check. + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMModel, Value: detail}) + }), nil } - vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) - route, outcome := m.matchRoute(model, vendor, requestPath(in.URL), in.UserGroups) + if model == "" { + return m.routeModelless(reqPath, surface, in.Method, in.UserGroups), nil + } + + route, outcome := m.matchRoute(model, surface, reqPath, in.UserGroups) + return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil +} + +// decide turns a per-model match result into the middleware's decision. Every +// surface that routes by model shares the same two denial arms — a model no +// route claims is not routable, one that some route claims but none authorises +// for this caller is an authorisation failure — so they live here once. +// decorate, when non-nil, adjusts the allow with whatever that surface needs. +func (m *Middleware) decide( + route ProviderRoute, + outcome matchOutcome, + surface, model string, + userGroups []string, + decorate func(*middleware.Output), +) *middleware.Output { switch outcome { case matchOutcomeFound: - return m.allowWithRoute(route, in.UserGroups), nil + out := m.allowWithRoute(route, surface, userGroups) + if decorate != nil { + decorate(out) + } + return out case matchOutcomeUnauthorised: - return denyNoAuthorisedRoute(model), nil + return denyNoAuthorisedRoute(surface, model) default: - return denyUnknownModel(model), nil + return denyUnknownModel(surface, model) + } +} + +// routeModelless serves the endpoints that name no model at all: the model +// listing, the connection-warming probe, and the Bedrock inference-profile +// lookup. They still need rewriting from the synth placeholder to a real +// upstream — clients such as Codex call GET /v1/models at startup to enumerate +// availability and read a 403 as "model unavailable". +func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups []string) *middleware.Output { + route, outcome := m.matchModelless(reqPath, method, userGroups) + switch outcome { + case matchOutcomeFound: + out := m.allowWithRoute(route, surface, userGroups) + markNonInference(out) + if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix { + stripBedrockNamespace(out) + } + // A route that enumerates its models bounds what the caller may use, + // so the picker must not offer the rest: every entry outside the list + // is a request the chain will deny. + if reqPath == modelListingPath && len(route.Models) > 0 && + out.Mutations != nil && out.Mutations.RewriteUpstream != nil { + out.Mutations.RewriteUpstream.DiscoveryModels = append([]string(nil), route.Models...) + } + return out + case matchOutcomeUnauthorised: + // A recognised model-less endpoint exists but no provider authorises + // the caller — deny as an authorisation failure rather than masking it + // as a missing model. + return denyNoAuthorisedRoute(surface, "") + default: + return denyMissingModel(surface) + } +} + +// isNonInferenceMethod reports whether a request method is one the +// non-inference endpoints actually use: the listing and the per-model lookup +// are GET, the connection-warming probe is HEAD or GET. The method is the only +// thing separating "GET /v1/models/{id}" from a POST to the same path carrying +// an inference body, and the non-inference mark exempts a request from the +// token pre-flight — so anything else falls through to normal per-model +// routing, which denies when the request names no model. +func isNonInferenceMethod(method string) bool { + return method == http.MethodGet || method == http.MethodHead +} + +// markNonInference tags an allow as a request that spends no tokens, so the +// limit check skips the management pre-flight it would charge nothing against. +func markNonInference(out *middleware.Output) { + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"}) +} + +// stripBedrockNamespace tells the rewrite to drop the optional "/bedrock" +// gateway namespace so the upstream receives its native Bedrock path. +func stripBedrockNamespace(out *middleware.Output) { + if out.Mutations != nil && out.Mutations.RewriteUpstream != nil { + out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix } } @@ -300,12 +369,60 @@ func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []stri return best, matchOutcomeFound } -// isModelLessPath reports whether reqPath is a known OpenAI-shaped -// non-inference endpoint that legitimately carries no model in its -// request (the model-listing endpoints). These must route to an upstream -// rather than deny, so model enumeration works end to end. +// connectionWarmPath is the probe Anthropic clients send before their first +// inference request to open the upstream connection early. Forwarding it +// warms the connection the request will actually use; denying it only fills +// the access log with rejections at every session start. +const connectionWarmPath = "/api/hello" + +// modelListingPath is the endpoint clients read at startup to populate +// their model picker. Its response is a list the proxy can bound; the +// per-model "/v1/models/{id}" lookup returns a single object and is left +// alone. +const modelListingPath = "/v1/models" + +// isModelLessPath reports whether reqPath is a known non-inference endpoint +// that legitimately carries no model at all: the model listing and the +// connection-warming probe. These must route to an upstream rather than +// deny, so model enumeration works end to end. The per-model +// "/v1/models/{id}" lookup is deliberately excluded — it names a model, so +// it is authorised against the model table instead (see modelDetailID). func isModelLessPath(reqPath string) bool { - return reqPath == "/v1/models" || strings.HasPrefix(reqPath, "/v1/models/") + return reqPath == modelListingPath || reqPath == connectionWarmPath +} + +// modelDetailID returns the model id named by a "/v1/models/{id}" lookup. +// reqPath comes from url.URL.Path, which is already percent-decoded, so an +// id carrying a "/" (a self-hosted "Qwen/Qwen2.5-0.5B-Instruct" sent as +// "Qwen%2FQwen2.5-...") arrives whole and everything after the prefix is the +// id, separators included. +func modelDetailID(reqPath string) (string, bool) { + if !strings.HasPrefix(reqPath, modelListingPath+"/") { + return "", false + } + id := strings.TrimPrefix(reqPath, modelListingPath+"/") + if id == "" { + return "", false + } + return id, true +} + +// isBedrockModelLessPath reports whether reqPath is a Bedrock +// inference-profile lookup, optionally behind the "/bedrock" gateway +// namespace. Clients read these at startup to resolve a configured profile +// to its underlying model. They carry no model of their own, so they route +// by path to a Bedrock provider rather than through the model table. +// +// On native AWS these live on the control plane ("bedrock.") while a +// provider's upstream is normally the runtime host ("bedrock-runtime."), +// so forwarding yields a 404 there. That is deliberate: a client has one base +// URL, so pointing it straight at the runtime host 404s identically, and +// forwarding keeps the proxy transparent instead of inventing a policy denial +// the client would never otherwise see. Operators whose Bedrock upstream is a +// gateway that does serve the lookup get a working answer. +func isBedrockModelLessPath(reqPath string) bool { + native, _ := splitBedrockNamespace(reqPath) + return native == "/inference-profiles" || strings.HasPrefix(native, "/inference-profiles/") } // isVertexPath reports whether reqPath is a Google Vertex AI publisher @@ -332,20 +449,33 @@ func splitBedrockNamespace(reqPath string) (string, bool) { return reqPath, false } +// bedrockActions are the runtime actions that follow the model id in a +// Bedrock path. count-tokens is here so a client can price its context +// against the dedicated endpoint; denying it pushes that work back onto +// the inference endpoint, which bills for it. +var bedrockActions = []string{ + "/invoke", + "/invoke-with-response-stream", + "/converse", + "/converse-stream", + "/count-tokens", +} + // isBedrockPath reports whether reqPath is an AWS Bedrock runtime model -// endpoint: /model/{modelId}/{action} where action is invoke, -// invoke-with-response-stream, converse, or converse-stream — optionally behind -// a "/bedrock" gateway-namespace prefix. The model lives in the path, so these -// requests are routed by path to the Bedrock provider. +// endpoint: /model/{modelId}/{action} — optionally behind a "/bedrock" +// gateway-namespace prefix. The model lives in the path, so these requests +// are routed by path to the Bedrock provider. func isBedrockPath(reqPath string) bool { native, _ := splitBedrockNamespace(reqPath) if !strings.HasPrefix(native, "/model/") { return false } - return strings.HasSuffix(native, "/invoke") || - strings.HasSuffix(native, "/invoke-with-response-stream") || - strings.HasSuffix(native, "/converse") || - strings.HasSuffix(native, "/converse-stream") + for _, action := range bedrockActions { + if strings.HasSuffix(native, action) { + return true + } + } + return false } // matchVertex selects the Vertex provider authorised for the caller's groups @@ -425,19 +555,26 @@ func (m *Middleware) matchPathRoute(reqPath, model string, userGroups []string, // declaration order), matchOutcomeUnauthorised when no provider authorises // the caller, or matchOutcomeUnknownModel when the path isn't a recognised // model-less endpoint. -func (m *Middleware) matchModelless(reqPath string, userGroups []string) (ProviderRoute, matchOutcome) { - if !isModelLessPath(reqPath) { +func (m *Middleware) matchModelless(reqPath, method string, userGroups []string) (ProviderRoute, matchOutcome) { + if !isNonInferenceMethod(method) { return ProviderRoute{}, matchOutcomeUnknownModel } - var candidates []ProviderRoute - for _, route := range m.cfg.Providers { + var eligible func(ProviderRoute) bool + switch { + case isBedrockModelLessPath(reqPath): + eligible = func(r ProviderRoute) bool { return r.Bedrock } + case isModelLessPath(reqPath): // Vertex/Bedrock are path-routed and don't serve OpenAI-style // model-listing endpoints; including them here could rewrite a // GET /v1/models to an upstream that 404s it. - if route.Vertex || route.Bedrock { - continue - } - if routeAuthorisesGroups(route, userGroups) { + eligible = func(r ProviderRoute) bool { return !r.Vertex && !r.Bedrock } + default: + return ProviderRoute{}, matchOutcomeUnknownModel + } + + var candidates []ProviderRoute + for _, route := range m.cfg.Providers { + if eligible(route) && routeAuthorisesGroups(route, userGroups) { candidates = append(candidates, route) } } @@ -564,6 +701,16 @@ func routeClaimsModel(route ProviderRoute, model string) bool { if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model { return true } + // A client may pin a dated Anthropic id ("claude-sonnet-4-5-20250929") + // where the operator registered the undated one. Only an undated + // registration absorbs a dated request: normalising both sides would + // let a route pinned to one dated release claim a different one, so an + // operator who deliberately pinned a build would silently serve + // another — and with several such routes, ordering would decide which. + if candidate == llm.NormalizeAnthropicModel(candidate) && + candidate == llm.NormalizeAnthropicModel(model) { + return true + } } return false } @@ -612,7 +759,7 @@ func requestPath(raw string) string { // provider id so identity-stamping middlewares (llm_identity_inject) // tag the request with ONLY the groups that authorised this specific // route — not every group the peer happens to be in. -func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *middleware.Output { +func (m *Middleware) allowWithRoute(route ProviderRoute, surface string, userGroups []string) *middleware.Output { rewrite := &middleware.UpstreamRewrite{ Scheme: route.UpstreamScheme, Host: route.UpstreamHost, @@ -634,7 +781,7 @@ func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *m // request time (cached + auto-refreshed) instead of a static value. bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64) if err != nil { - return denyUpstreamAuth() + return denyUpstreamAuth(surface) } authValue = bearer } @@ -704,11 +851,12 @@ func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error) // denyUpstreamAuth is returned when the router cannot obtain the upstream // credential (e.g. a malformed service-account key or an unreachable token // endpoint). It surfaces as a 502 — an upstream problem, not a policy denial. -func denyUpstreamAuth() *middleware.Output { +func denyUpstreamAuth(surface string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 502, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeUpstreamAuth, Message: "could not obtain upstream credential", }, @@ -722,11 +870,12 @@ func denyUpstreamAuth() *middleware.Output { // denyUnmeterable returns the deny envelope for a path-routed request whose // publisher has no parser surface, so its usage can't be metered. Serving it // would bypass token/budget caps, so it is rejected with a 403. -func denyUnmeterable() *middleware.Output { +func denyUnmeterable(surface string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeUnmeterable, Message: "request publisher is not supported for metering", }, @@ -739,11 +888,12 @@ func denyUnmeterable() *middleware.Output { // denyMissingModel returns the deny envelope for a request whose // envelope has no llm.model metadata. -func denyMissingModel() *middleware.Output { +func denyMissingModel(surface string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeNotRoutable, Message: "missing llm.model on request envelope", }, @@ -756,11 +906,12 @@ func denyMissingModel() *middleware.Output { // denyUnknownModel returns the deny envelope for a model that no // configured provider claims. -func denyUnknownModel(model string) *middleware.Output { +func denyUnknownModel(surface, model string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeNotRoutable, Message: fmt.Sprintf("no provider configured for model %s", model), Details: map[string]string{"model": model}, @@ -775,11 +926,12 @@ func denyUnknownModel(model string) *middleware.Output { // denyNoAuthorisedRoute returns the deny envelope for a model that one // or more providers claim, but where no policy authorises the caller's // groups for any of those providers. -func denyNoAuthorisedRoute(model string) *middleware.Output { +func denyNoAuthorisedRoute(surface, model string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeNoAuthorisedRoute, Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model), Details: map[string]string{"model": model}, diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go index 425c383c1..336cdb9fe 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -2,6 +2,7 @@ package llm_router import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -60,6 +61,8 @@ func TestMiddlewareIdentity(t *testing.T) { []string{ middleware.KeyLLMResolvedProviderID, middleware.KeyLLMAuthorisingGroups, + middleware.KeyLLMNonInference, + middleware.KeyLLMModel, middleware.KeyLLMPolicyDecision, middleware.KeyLLMPolicyReason, }, @@ -171,8 +174,12 @@ func TestRouter_MissingModel(t *testing.T) { // from which a model could be parsed). UserGroups matches defaultTestGroup. func newModellessInput(reqURL string) *middleware.Input { return &middleware.Input{ - Slot: middleware.SlotOnRequest, - URL: reqURL, + Slot: middleware.SlotOnRequest, + URL: reqURL, + // The non-inference endpoints are read requests; the method is what + // separates them from an inference body posted to the same path, so + // state it rather than leaning on the zero value. + Method: http.MethodGet, UserGroups: []string{defaultTestGroup}, } } @@ -197,6 +204,12 @@ func TestRouter_ModelLessPath_RoutesToAuthorisedProvider(t *testing.T) { provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) assert.Equal(t, "openai-prod", provider, "resolved provider must be the authorised route") + + // The limits gate reads this to tell "no model applies here" from + // "the model could not be determined", which fails closed. + nonInference, ok := metaValue(t, out.Metadata, middleware.KeyLLMNonInference) + require.True(t, ok, "model-less allow must mark the request non-inference") + assert.Equal(t, "true", nonInference) } func TestRouter_ModelLessPath_MultiProviderDeclarationOrder(t *testing.T) { @@ -873,3 +886,262 @@ func TestRouter_EmptyModelsClaimsAnyModel(t *testing.T) { resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) assert.Equal(t, "litellm", resolved) } + +// TestRouter_DatedAnthropicModelRoutes covers a client pinning a release +// date on a model the operator registered undated. Exact matches still win, +// so an operator who registers both dated releases keeps them distinct. +func TestRouter_DatedAnthropicModelRoutes(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "anthropic-prod", + Vendor: "anthropic", + Models: []string{"claude-sonnet-4-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + }}}) + + in := newInputWithModelAndURL("claude-sonnet-4-5-20250929", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "a dated id must route to the undated registration") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host) +} + +// TestRouter_ConnectionWarmProbeRoutes covers the HEAD /api/hello probe an +// Anthropic client sends before its first request. Forwarding it warms the +// connection that request will use; denying it only wrote a rejection into +// the access log at every session start. +func TestRouter_ConnectionWarmProbeRoutes(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "anthropic-prod", + Vendor: "anthropic", + Models: []string{"claude-sonnet-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + }}}) + + in := newModellessInput("/api/hello") + in.Method = http.MethodHead + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "the warm-up probe must reach the upstream") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host) + + nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference) + assert.Equal(t, "true", nonInference, "the probe carries no model to gate on") +} + +// TestRouter_ModelListingCarriesAuthorisedModels pins the list the proxy +// bounds the discovery response with. A catch-all route enumerates nothing, +// so it must not bound the upstream's list at all. +func TestRouter_ModelListingCarriesAuthorisedModels(t *testing.T) { + enumerated := ProviderRoute{ + ID: "anthropic-prod", + Models: []string{"claude-sonnet-5", "claude-haiku-4-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + + t.Run("enumerated route bounds the listing", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{enumerated}}) + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models")) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, []string{"claude-sonnet-5", "claude-haiku-4-5"}, + out.Mutations.RewriteUpstream.DiscoveryModels, + "the picker must be bounded by what the route authorises") + }) + + t.Run("catch-all route leaves the listing alone", func(t *testing.T) { + catchAll := enumerated + catchAll.Models = nil + mw := New(Config{Providers: []ProviderRoute{catchAll}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models")) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels, + "a route that claims every model cannot bound the upstream's list") + }) + + t.Run("per-model lookup is not a listing", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{enumerated}}) + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5")) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels, + "the single-object lookup has no data array to filter") + }) +} + +// TestRouter_ModelDetailHonoursAllowlist pins that GET /v1/models/{id} is +// authorised against the model table. It carries no body model, so treating +// it as a model-less endpoint would let a caller confirm a model the route +// does not list — the listing itself is bounded to the allowlist, so the +// detail lookup must be too. +func TestRouter_ModelDetailHonoursAllowlist(t *testing.T) { + enumerated := ProviderRoute{ + ID: "anthropic-prod", + Models: []string{"claude-sonnet-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + + t.Run("allowlisted model routes and skips metering", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{enumerated}}) + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host) + + nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference) + assert.Equal(t, "true", nonInference, "a detail lookup spends no tokens") + }) + + t.Run("model outside the allowlist denies", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{enumerated}}) + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-opus-5")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "a model no route lists must not be confirmed by the detail lookup") + }) + + t.Run("dated id matches its undated registration", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{enumerated}}) + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5-20250929")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a pinned release of an allowlisted family stays reachable") + }) + + t.Run("catch-all route still answers every lookup", func(t *testing.T) { + catchAll := enumerated + catchAll.Models = nil + mw := New(Config{Providers: []ProviderRoute{catchAll}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/anything-at-all")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a gateway that enumerates nothing cannot refuse a lookup") + }) +} + +// TestRouter_NonInferenceRequiresReadMethod pins that the non-inference mark — +// which exempts a request from the token pre-flight — is reachable only by the +// read methods these endpoints actually use. A POST to the same path could +// carry an inference body, so it must not buy the exemption; it falls through +// to normal per-model routing instead, which denies when no model is named. +func TestRouter_NonInferenceRequiresReadMethod(t *testing.T) { + route := ProviderRoute{ + ID: "gateway", + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "gateway.example.com", + } + + for _, path := range []string{"/v1/models", "/v1/models/claude-sonnet-5", "/api/hello"} { + t.Run("POST "+path, func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{route}}) + + in := newModellessInput(path) + in.Method = http.MethodPost + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "a write to a non-inference path must not route unmetered") + + nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference) + assert.NotEqual(t, "true", nonInference, + "only a read method may skip the token pre-flight") + }) + } + + t.Run("HEAD keeps the warm probe working", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{route}}) + + in := newModellessInput(connectionWarmPath) + in.Method = http.MethodHead + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "the HEAD warm probe must still reach the upstream") + + nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference) + assert.Equal(t, "true", nonInference, + "the HEAD warm probe carries no model to meter") + }) +} + +// TestRouter_PinnedDatedModelStaysDistinct pins that a route registered +// against one dated Anthropic release does not claim another. Normalising +// both sides of the comparison made every dated build of a family +// interchangeable, so an operator who deliberately pinned a build would have +// served a different one — and with several such routes, declaration or path +// order would have decided which. +func TestRouter_PinnedDatedModelStaysDistinct(t *testing.T) { + pinned := ProviderRoute{ + ID: "anthropic-pinned", + Vendor: "anthropic", + Models: []string{"claude-sonnet-4-5-20250101"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "pinned.example.com", + } + + t.Run("a different dated release is not claimed", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{pinned}}) + in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "a route pinned to one dated build must not serve another") + }) + + t.Run("its own dated release still routes", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{pinned}}) + in := newInputWithModelAndURL("claude-sonnet-4-5-20250101", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "the exact match must still route") + }) + + t.Run("two pinned builds each route to their own provider", func(t *testing.T) { + other := pinned + other.ID = "anthropic-pinned-newer" + other.Models = []string{"claude-sonnet-4-5-20250202"} + other.UpstreamHost = "newer.example.com" + mw := New(Config{Providers: []ProviderRoute{pinned, other}}) + + in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "newer.example.com", out.Mutations.RewriteUpstream.Host, + "declaration order must not decide between two deliberately pinned builds") + }) +} diff --git a/proxy/internal/middleware/decision.go b/proxy/internal/middleware/decision.go index 0970bdea4..97dca4af5 100644 --- a/proxy/internal/middleware/decision.go +++ b/proxy/internal/middleware/decision.go @@ -11,11 +11,78 @@ var codeRegex = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) // denyResponse is the on-wire shape rendered by RenderDenyResponse. // Keeping this as a typed struct ensures we never leak // middleware-supplied bytes outside known fields. +// +// Type and Error mirror the denial in the vendor's own error shape when +// the request reached a known LLM surface. LLM clients only parse their +// provider's envelope, so without the mirror a budget stop reaches the +// user as an unexplained API error. The NetBird fields stay where they +// were, so the body is a superset and existing consumers are unaffected. type denyResponse struct { Code string `json:"code"` Message string `json:"message,omitempty"` Details map[string]string `json:"details,omitempty"` Middleware string `json:"middleware,omitempty"` + Type string `json:"type,omitempty"` + Error *providerError `json:"error,omitempty"` +} + +// providerError is the nested error object both vendor envelopes carry. +type providerError struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +// Vendor error types keyed by HTTP status, per each provider's published +// error reference. +const ( + anthropicErrInvalidRequest = "invalid_request_error" + anthropicErrPermission = "permission_error" + anthropicErrRateLimit = "rate_limit_error" + anthropicErrAPI = "api_error" + openAIErrInvalidRequest = "invalid_request_error" + openAIErrRateLimit = "rate_limit_error" +) + +// providerEnvelope returns the vendor-shaped mirror for a denial on the +// given surface, or nil when the surface has no envelope we can speak. +// message is the already-redacted public message. +func providerEnvelope(surface, code, message string, status int) (string, *providerError) { + switch surface { + case "anthropic": + return "error", &providerError{ + Type: anthropicErrorType(status), + Message: message, + } + case "openai": + return "", &providerError{ + Type: openAIErrorType(status), + Message: message, + Code: code, + } + default: + return "", nil + } +} + +func anthropicErrorType(status int) string { + switch status { + case http.StatusForbidden: + return anthropicErrPermission + case http.StatusTooManyRequests: + return anthropicErrRateLimit + case http.StatusBadRequest: + return anthropicErrInvalidRequest + default: + return anthropicErrAPI + } +} + +func openAIErrorType(status int) string { + if status == http.StatusTooManyRequests { + return openAIErrRateLimit + } + return openAIErrInvalidRequest } // RenderDenyResponse writes a structured JSON deny body. Status is @@ -36,6 +103,7 @@ func RenderDenyResponse(w http.ResponseWriter, middlewareID string, reason *Deny Message: truncate(Scan(reason.Message), 256), Middleware: truncate(Scan(middlewareID), 64), } + resp.Type, resp.Error = providerEnvelope(reason.Surface, resp.Code, resp.Message, status) if n := len(reason.Details); n > 0 { resp.Details = make(map[string]string, min(n, 8)) for k, v := range reason.Details { diff --git a/proxy/internal/middleware/decision_test.go b/proxy/internal/middleware/decision_test.go new file mode 100644 index 000000000..cf14c86ff --- /dev/null +++ b/proxy/internal/middleware/decision_test.go @@ -0,0 +1,92 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// decodeDeny renders a denial and returns the parsed body plus the status. +func decodeDeny(t *testing.T, reason *DenyReason, status int) (map[string]any, int) { + t.Helper() + rec := httptest.NewRecorder() + RenderDenyResponse(rec, "llm_limit_check", reason, status) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), "deny body must be valid JSON") + return body, rec.Code +} + +// TestRenderDeny_AnthropicSurfaceMirrorsVendorShape covers a budget stop +// reaching Claude Code. The client only parses the Anthropic envelope, so +// without the mirror the user sees an unexplained API error instead of the +// reason their request was refused. +func TestRenderDeny_AnthropicSurfaceMirrorsVendorShape(t *testing.T) { + body, status := decodeDeny(t, &DenyReason{ + Code: "llm_policy.budget_cap_exceeded", + Message: "LLM policy limit exceeded", + Surface: "anthropic", + }, http.StatusForbidden) + + assert.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "error", body["type"], "Anthropic errors carry type=error at the top level") + + errObj, ok := body["error"].(map[string]any) + require.True(t, ok, "error must be an object") + assert.Equal(t, "permission_error", errObj["type"], "403 maps to permission_error") + assert.Equal(t, "LLM policy limit exceeded", errObj["message"]) + + // The NetBird fields stay put so existing consumers keep working. + assert.Equal(t, "llm_policy.budget_cap_exceeded", body["code"]) + assert.Equal(t, "LLM policy limit exceeded", body["message"]) + assert.Equal(t, "llm_limit_check", body["middleware"]) +} + +// TestRenderDeny_OpenAISurfaceMirrorsVendorShape pins the OpenAI envelope, +// which nests the code and carries no top-level type. +func TestRenderDeny_OpenAISurfaceMirrorsVendorShape(t *testing.T) { + body, _ := decodeDeny(t, &DenyReason{ + Code: "llm_policy.model_blocked", + Message: "model is not in the policy allowlist", + Surface: "openai", + }, http.StatusForbidden) + + assert.NotContains(t, body, "type", "OpenAI errors have no top-level type") + + errObj, ok := body["error"].(map[string]any) + require.True(t, ok, "error must be an object") + assert.Equal(t, "invalid_request_error", errObj["type"]) + assert.Equal(t, "llm_policy.model_blocked", errObj["code"], "the NetBird code rides in the vendor code field") + assert.Equal(t, "model is not in the policy allowlist", errObj["message"]) +} + +// TestRenderDeny_RateLimitStatusMapsToVendorRateLimit pins the mapping a +// client's backoff keys on. +func TestRenderDeny_RateLimitStatusMapsToVendorRateLimit(t *testing.T) { + body, status := decodeDeny(t, &DenyReason{ + Code: "llm_policy.token_cap_exceeded", + Message: "LLM policy limit exceeded", + Surface: "anthropic", + }, http.StatusTooManyRequests) + + assert.Equal(t, http.StatusTooManyRequests, status, "429 must survive the status clamp") + errObj := body["error"].(map[string]any) + assert.Equal(t, "rate_limit_error", errObj["type"]) +} + +// TestRenderDeny_NoSurfaceKeepsLegacyShape guards non-LLM middlewares and +// denials raised before a surface is known. +func TestRenderDeny_NoSurfaceKeepsLegacyShape(t *testing.T) { + body, _ := decodeDeny(t, &DenyReason{ + Code: "llm_policy.model_not_routable", + Message: "no provider configured for model x", + }, http.StatusForbidden) + + assert.NotContains(t, body, "type", "no surface means no vendor mirror") + assert.NotContains(t, body, "error", "no surface means no vendor mirror") + assert.Equal(t, "llm_policy.model_not_routable", body["code"]) +} diff --git a/proxy/internal/middleware/keys.go b/proxy/internal/middleware/keys.go index 336bed19f..eff3fa756 100644 --- a/proxy/internal/middleware/keys.go +++ b/proxy/internal/middleware/keys.go @@ -22,6 +22,15 @@ const ( // body. Empty for clients that don't send one. KeyLLMSessionID = "llm.session_id" + // Sub-agent attribution (emitted by llm_request_parser from the + // client's request headers). A coding agent that spawns helpers + // stamps the spawned agent's id, and the spawning agent's id when + // the helper is itself nested, so cost within one session can be + // split across the agents that ran in parallel. These identify an + // agent, not a person or a device: never treat them as a user id. + KeyLLMAgentID = "llm.agent_id" + KeyLLMParentAgentID = "llm.parent_agent_id" + // LLM response-side metadata (emitted by llm_response_parser). //nolint:gosec // metadata key name, not a credential KeyLLMInputTokens = "llm.input_tokens" @@ -66,6 +75,14 @@ const ( // downstream gateways' spend logs. KeyLLMAuthorisingGroups = "llm.authorising_groups" + // LLM non-inference marker (emitted by llm_router on the allow path + // for endpoints that legitimately carry no model, such as model + // listing). The router still authorises these against the caller's + // groups; the marker only tells the limits gate that a per-model + // allowlist has nothing to evaluate, so an empty model must not be + // read as an undetermined one. Never derived from client input. + KeyLLMNonInference = "llm.non_inference" + // LLM policy attribution (emitted by llm_limit_check on the allow // path). Names the policy that paid for this request and the // dimension counters the post-flight llm_limit_record middleware diff --git a/proxy/internal/middleware/types.go b/proxy/internal/middleware/types.go index 1ed5c9d88..3c0ac0ab6 100644 --- a/proxy/internal/middleware/types.go +++ b/proxy/internal/middleware/types.go @@ -179,6 +179,12 @@ type DenyReason struct { Code string Message string Details map[string]string + // Surface names the LLM API dialect the caller speaks (the + // llm.provider value), so the rendered body can mirror the denial in + // that vendor's error shape alongside the NetBird fields. Empty for + // non-LLM middlewares and for denials raised before a surface was + // resolved; the body then carries the NetBird fields alone. + Surface string } // Output is the value each middleware returns to the dispatcher. The @@ -247,6 +253,12 @@ type UpstreamRewrite struct { // without verifying its TLS certificate. Set by llm_router from the // provider's skip_tls_verification for self-hosted / internal gateways. SkipTLSVerify bool + // DiscoveryModels, when non-empty, is the set of model ids the resolved + // route authorises, and the proxy drops everything else from the + // model-listing response. Empty leaves the upstream's list untouched, + // which is what a route that claims every model wants. Set by + // llm_router on a model-listing request only. + DiscoveryModels []string } // AuthHeader is a single name/value pair the proxy injects on the diff --git a/proxy/internal/proxy/discovery_filter.go b/proxy/internal/proxy/discovery_filter.go new file mode 100644 index 000000000..c9d606970 --- /dev/null +++ b/proxy/internal/proxy/discovery_filter.go @@ -0,0 +1,215 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + sharedllm "github.com/netbirdio/netbird/shared/llm" +) + +// maxDiscoveryBodyBytes bounds the model-listing response the filter will +// buffer. A listing is a few kilobytes of ids; anything larger is not a +// listing we recognise, and buffering it to rewrite would cost more than +// the filtering is worth. +const maxDiscoveryBodyBytes = 1 << 20 + +// modelDiscoveryFilter returns a ModifyResponse hook that drops models the +// caller's policy does not authorise from a model-listing response, then +// delegates to next (which may be nil). +// +// Clients populate their model picker from this endpoint, so an unfiltered +// list offers models the very next request denies. The filter is +// best-effort: a response it cannot safely rewrite passes through +// untouched rather than reaching the client corrupted. +func modelDiscoveryFilter(allowed []string, next func(*http.Response) error) func(*http.Response) error { + permitted := make(map[string]struct{}, len(allowed)*2) + for _, id := range allowed { + permitted[id] = struct{}{} + permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{} + } + + return func(resp *http.Response) error { + if err := filterModelListing(resp, permitted); err != nil { + return err + } + if next == nil { + return nil + } + return next(resp) + } +} + +// filterModelListing rewrites the response body in place, keeping only the +// entries whose id the policy authorises. Responses that are not a plain +// JSON listing are left alone. +func filterModelListing(resp *http.Response, permitted map[string]struct{}) error { + if !isPlainJSONListing(resp) { + return nil + } + + // One byte past the cap, so an oversized body is detectable without + // buffering all of it. + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryBodyBytes+1)) + if err != nil { + _ = resp.Body.Close() + return err + } + if len(body) > maxDiscoveryBodyBytes { + // Too large to filter. Put the bytes already read back in front of the + // unread remainder and forward the response exactly as the upstream + // sent it, headers included. Buffering what was read and closing here + // would truncate the body at the cap and hand the client a short, + // invalid listing — worse than not filtering at all. + resp.Body = spliceBody(body, resp.Body) + return nil + } + if err := resp.Body.Close(); err != nil { + return err + } + + filtered, ok := filterListingBody(body, permitted) + if !ok { + restoreBody(resp, body) + return nil + } + restoreBody(resp, filtered) + return nil +} + +// isPlainJSONListing reports whether the response is a JSON body the filter +// can parse. A content-encoded body is skipped: the transport only +// transparently decompresses what it negotiated itself, and the client +// negotiates its own encoding on this request. +func isPlainJSONListing(resp *http.Response) bool { + if resp == nil || resp.Body == nil { + return false + } + if resp.StatusCode != http.StatusOK { + return false + } + if enc := resp.Header.Get("Content-Encoding"); enc != "" && !strings.EqualFold(enc, "identity") { + return false + } + return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json") +} + +// filterListingBody returns the listing with unauthorised entries removed. +// ok is false when the body is not a listing shape, in which case the +// caller must forward the original bytes. +func filterListingBody(body []byte, permitted map[string]struct{}) ([]byte, bool) { + var doc map[string]json.RawMessage + if err := json.Unmarshal(body, &doc); err != nil { + return nil, false + } + raw, present := doc["data"] + if !present { + return nil, false + } + var entries []map[string]json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, false + } + + kept := make([]map[string]json.RawMessage, 0, len(entries)) + for _, entry := range entries { + if entryPermitted(entry, permitted) { + kept = append(kept, entry) + } + } + + encoded, err := json.Marshal(kept) + if err != nil { + return nil, false + } + doc["data"] = encoded + out, err := json.Marshal(doc) + if err != nil { + return nil, false + } + return out, true +} + +// entryPermitted reports whether a listing entry names a model the policy +// authorises, trying every form the same model is written in. +func entryPermitted(entry map[string]json.RawMessage, permitted map[string]struct{}) bool { + raw, ok := entry["id"] + if !ok { + return false + } + var id string + if err := json.Unmarshal(raw, &id); err != nil { + return false + } + for _, candidate := range modelIDForms(id) { + if _, ok := permitted[candidate]; ok { + return true + } + } + return false +} + +// gatewayNamespaces are the provider prefixes a gateway prepends to a model +// it re-exports: LiteLLM lists a Bedrock model the operator registered as +// "anthropic.claude-opus-5" under "bedrock/anthropic.claude-opus-5". Only +// these are stripped before matching. +// +// A slash is not by itself a namespace separator. Self-hosted backends ship +// ids that carry one ("Qwen/Qwen2.5-0.5B-Instruct"), and an upstream is free +// to scope ids per tenant ("tenant-b/claude-sonnet-5"). Treating every slash +// as a prefix let any such id match an allowed model by its tail, so the +// picker offered models the policy never named. +var gatewayNamespaces = map[string]struct{}{ + "anthropic": {}, + "azure": {}, + "bedrock": {}, + "mistral": {}, + "openai": {}, + "vertex_ai": {}, +} + +// modelIDForms returns the forms a single model id may be written in: the id +// itself, its undated form, and — when the id is namespaced by a gateway we +// recognise — the same two with that namespace removed +// ("vertex_ai/claude-sonnet-5"). The bare id is always tried first. +// +// The namespace is what precedes the FIRST slash: it is a prefix the gateway +// put in front of the whole id, and everything after it is the id the +// operator would have registered, separators included. +func modelIDForms(id string) []string { + if id == "" { + return nil + } + forms := []string{id, sharedllm.NormalizeAnthropicModel(id)} + if slash := strings.Index(id, "/"); slash > 0 { + if _, ok := gatewayNamespaces[id[:slash]]; ok { + tail := id[slash+1:] + forms = append(forms, tail, sharedllm.NormalizeAnthropicModel(tail)) + } + } + return forms +} + +// restoreBody puts body back on the response and fixes the length headers +// so the client reads exactly what is there. +// spliceBody returns a ReadCloser that yields prefix followed by whatever is +// left in rest, closing rest when closed. It lets the filter put back bytes it +// consumed while deciding, without owning the rest of the stream. +func spliceBody(prefix []byte, rest io.ReadCloser) io.ReadCloser { + return struct { + io.Reader + io.Closer + }{ + Reader: io.MultiReader(bytes.NewReader(prefix), rest), + Closer: rest, + } +} + +func restoreBody(resp *http.Response, body []byte) { + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) +} diff --git a/proxy/internal/proxy/discovery_filter_test.go b/proxy/internal/proxy/discovery_filter_test.go new file mode 100644 index 000000000..103eac594 --- /dev/null +++ b/proxy/internal/proxy/discovery_filter_test.go @@ -0,0 +1,235 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// jsonListingResponse builds a 200 model-listing response with the given +// body, as an upstream would return it. +func jsonListingResponse(body string) *http.Response { + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + } + resp.Header.Set("Content-Type", "application/json") + return resp +} + +// listedIDs runs the filter and returns the ids left in the response. +func listedIDs(t *testing.T, allowed []string, body string) []string { + t.Helper() + resp := jsonListingResponse(body) //nolint:bodyclose // in-memory body, replaced by the filter + require.NoError(t, modelDiscoveryFilter(allowed, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter + + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var doc struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(raw, &doc), "filtered body must stay valid JSON") + + ids := make([]string, 0, len(doc.Data)) + for _, entry := range doc.Data { + ids = append(ids, entry.ID) + } + return ids +} + +// TestModelDiscoveryFilter_KeepsOnlyAuthorisedModels covers the picker a +// developer sees: an unfiltered upstream list offers every model the shared +// key can reach, and each one the policy excludes is a request the chain +// denies a moment later. +func TestModelDiscoveryFilter_KeepsOnlyAuthorisedModels(t *testing.T) { + ids := listedIDs(t, []string{"claude-sonnet-5", "claude-haiku-4-5"}, `{ + "data": [ + {"id": "claude-opus-5", "display_name": "Claude Opus 5"}, + {"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5"}, + {"id": "claude-haiku-4-5"} + ], + "has_more": false + }`) + + assert.Equal(t, []string{"claude-sonnet-5", "claude-haiku-4-5"}, ids, + "only the models the route authorises may reach the picker") +} + +// TestModelDiscoveryFilter_MatchesDatedAndPrefixedIDs pins the two id forms +// a gateway returns for a model the operator registered plainly. +func TestModelDiscoveryFilter_MatchesDatedAndPrefixedIDs(t *testing.T) { + ids := listedIDs(t, []string{"claude-sonnet-4-5", "anthropic.claude-opus-5"}, `{ + "data": [ + {"id": "claude-sonnet-4-5-20250929"}, + {"id": "bedrock/anthropic.claude-opus-5"}, + {"id": "gpt-4o"} + ] + }`) + + assert.Equal(t, []string{"claude-sonnet-4-5-20250929", "bedrock/anthropic.claude-opus-5"}, ids, + "a dated or provider-prefixed id must match its registered form") +} + +// TestModelDiscoveryFilter_PreservesEnvelopeFields guards the rest of the +// document: clients read paging fields alongside data. +func TestModelDiscoveryFilter_PreservesEnvelopeFields(t *testing.T) { + resp := jsonListingResponse(`{"data":[{"id":"claude-sonnet-5"}],"has_more":true,"first_id":"x"}`) //nolint:bodyclose // in-memory body, replaced by the filter + require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter + + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + assert.Equal(t, true, doc["has_more"], "paging fields must survive the rewrite") + assert.Equal(t, "x", doc["first_id"]) + assert.Equal(t, strconv.Itoa(len(raw)), resp.Header.Get("Content-Length"), + "Content-Length must match the rewritten body") +} + +// TestModelDiscoveryFilter_PassesThroughUnfilterable covers the responses +// the filter must not touch: a compressed body it cannot parse, a non-JSON +// body, an error status, and a document with no data array. +func TestModelDiscoveryFilter_PassesThroughUnfilterable(t *testing.T) { + cases := map[string]func() *http.Response{ + "compressed": func() *http.Response { + resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`) + resp.Header.Set("Content-Encoding", "gzip") + return resp + }, + "not json": func() *http.Response { + resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`) + resp.Header.Set("Content-Type", "text/html") + return resp + }, + "error status": func() *http.Response { + resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`) + resp.StatusCode = http.StatusInternalServerError + return resp + }, + "no data array": func() *http.Response { + return jsonListingResponse(`{"object":"list"}`) + }, + } + + for name, build := range cases { + t.Run(name, func(t *testing.T) { + resp := build() //nolint:bodyclose // in-memory body, replaced by the filter + original, err := io.ReadAll(resp.Body) + require.NoError(t, err) + resp.Body = io.NopCloser(bytes.NewReader(original)) + + require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter + + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, string(original), string(got), "an unfilterable response must reach the client unchanged") + }) + } +} + +// TestModelDiscoveryFilter_RunsNextHook pins that an existing +// ModifyResponse hook still runs after filtering. +func TestModelDiscoveryFilter_RunsNextHook(t *testing.T) { + called := false + next := func(*http.Response) error { + called = true + return nil + } + + resp := jsonListingResponse(`{"data":[{"id":"claude-sonnet-5"}]}`) //nolint:bodyclose // in-memory body, replaced by the filter + require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, next)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter + assert.True(t, called, "the chained hook must still run") +} + +// TestModelDiscoveryFilter_KeepsSlashBearingIDs covers self-hosted backends +// whose model ids carry a slash of their own. Treating the slash as a +// gateway prefix and keeping only the tail dropped every such model from +// the picker even though the policy named it exactly. +func TestModelDiscoveryFilter_KeepsSlashBearingIDs(t *testing.T) { + ids := listedIDs(t, []string{"Qwen/Qwen2.5-0.5B-Instruct"}, `{ + "object": "list", + "data": [ + {"id": "Qwen/Qwen2.5-0.5B-Instruct"}, + {"id": "Qwen/Qwen2.5-7B-Instruct"} + ] + }`) + + assert.Equal(t, []string{"Qwen/Qwen2.5-0.5B-Instruct"}, ids, + "a slash inside the model id is part of the id, not a provider prefix") +} + +// TestModelDiscoveryFilter_RejectsTailMatchOnUnknownNamespace covers the id +// an upstream scopes with a prefix of its own. "tenant-b/claude-sonnet-5" +// ends in a model the policy permits, but it is a different model on a +// different tenant, and the guardrail denies that string outright — so +// offering it hands the picker an entry the next request refuses. +func TestModelDiscoveryFilter_RejectsTailMatchOnUnknownNamespace(t *testing.T) { + ids := listedIDs(t, []string{"claude-sonnet-5"}, `{ + "data": [ + {"id": "claude-sonnet-5"}, + {"id": "tenant-b/claude-sonnet-5"}, + {"id": "Qwen/claude-sonnet-5"} + ] + }`) + + assert.Equal(t, []string{"claude-sonnet-5"}, ids, + "only a namespace a gateway is known to prepend may be stripped before matching") +} + +// TestModelDiscoveryFilter_ForwardsOversizedBodyIntact covers a listing past +// the buffering cap. The filter reads one byte beyond the cap to detect the +// size; forwarding only what it read would hand the client a body truncated +// at exactly 1 MiB — valid-looking, short, and unparseable as JSON. The bytes +// already read must be spliced back in front of the unread remainder so the +// response reaches the client exactly as the upstream sent it. +func TestModelDiscoveryFilter_ForwardsOversizedBodyIntact(t *testing.T) { + // A well-formed listing whose single entry pads the body past the cap. + padding := strings.Repeat("x", maxDiscoveryBodyBytes) + body := `{"object":"list","data":[{"id":"gpt-4o","note":"` + padding + `"}]}` + require.Greater(t, len(body), maxDiscoveryBodyBytes+1, + "the fixture must exceed the cap by more than the one-byte probe") + + resp := jsonListingResponse(body) //nolint:bodyclose // in-memory body + require.NoError(t, modelDiscoveryFilter(nil, nil)(resp)) //nolint:bodyclose // in-memory body + + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, len(body), len(got), + "an oversized listing must reach the client whole, not truncated at the cap") + assert.Equal(t, body, string(got), "the forwarded bytes must be the upstream's own") + + var doc map[string]json.RawMessage + assert.NoError(t, json.Unmarshal(got, &doc), + "the forwarded body must still parse as JSON") +} + +// TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders pins that the +// oversized path leaves the response metadata alone. Rewriting Content-Length +// to the truncated prefix is what made the corruption invisible to the client +// until it tried to parse. +func TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders(t *testing.T) { + padding := strings.Repeat("x", maxDiscoveryBodyBytes) + body := `{"object":"list","data":[{"id":"gpt-4o","note":"` + padding + `"}]}` + + resp := jsonListingResponse(body) //nolint:bodyclose // in-memory body + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) + require.NoError(t, modelDiscoveryFilter(nil, nil)(resp)) //nolint:bodyclose // in-memory body + + assert.Equal(t, int64(len(body)), resp.ContentLength, + "ContentLength must keep describing the body the client receives") + assert.Equal(t, strconv.Itoa(len(body)), resp.Header.Get("Content-Length"), + "the Content-Length header must not be rewritten to the truncated prefix") +} diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 9150c0329..7c9e21261 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -363,6 +363,9 @@ func (p *ReverseProxy) forwardUpstream(respWriter http.ResponseWriter, r *http.R if result.rewriteRedirects { rp.ModifyResponse = p.rewriteLocationFunc(effectiveURL, rewriteMatchedPath, r) //nolint:bodyclose } + if upstreamRewrite != nil && len(upstreamRewrite.DiscoveryModels) > 0 { + rp.ModifyResponse = modelDiscoveryFilter(upstreamRewrite.DiscoveryModels, rp.ModifyResponse) //nolint:bodyclose // the hook replaces the body and closes the original + } rp.ServeHTTP(respWriter, r.WithContext(ctx)) } diff --git a/proxy/server.go b/proxy/server.go index bd70b7e70..aee748339 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -2074,9 +2074,17 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err) } m := s.protoToMapping(ctx, mapping) - s.proxy.AddMapping(m) + // The chain is published before the route that leads to it. A request + // arriving at a target whose chain has not been rebuilt yet is served + // straight through, so a provider update that added the route first left a + // window in which an inference could complete unrouted and unmetered. + // Rebuilding first inverts that: the worst a request in the window meets is + // the new chain in front of the previous target, which is still counted. + if err := s.rebuildMiddlewareChains(svcID, m); err != nil { + return err + } s.meter.AddMapping(m) - s.rebuildMiddlewareChains(svcID, m) + s.proxy.AddMapping(m) return nil } @@ -2114,15 +2122,21 @@ func (s *Server) initMiddlewareManager(ctx context.Context) error { } // rebuildMiddlewareChains converts m into per-path bindings and calls -// Manager.Rebuild. Short-circuits when the middleware manager is unset. -func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) { +// Manager.Rebuild. Short-circuits when the middleware manager is unset, which +// is a deployment without middleware rather than a failure to install it. +// +// A rebuild that fails is reported rather than logged: the caller publishes +// the route once this returns, and a route published over chains that were +// not installed serves requests with no policy enforcement and no metering. +func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) error { if s.middlewareManager == nil { - return + return nil } bindings := buildMiddlewareBindings(svcID, m) if err := s.middlewareManager.Rebuild(string(svcID), bindings); err != nil { - s.Logger.WithError(err).WithField("service_id", svcID).Error("failed to rebuild middleware chains") + return fmt.Errorf("rebuild middleware chains for service %s: %w", svcID, err) } + return nil } // isLiveService reports whether svcID is currently present in the live diff --git a/shared/llm/model.go b/shared/llm/model.go index 08e42e5a4..4fb631520 100644 --- a/shared/llm/model.go +++ b/shared/llm/model.go @@ -46,6 +46,27 @@ func NormalizeBedrockModel(modelID string) string { return bedrockVersionSuffix.ReplaceAllString(m, "") } +// anthropicDatedModel matches a Claude model id carrying the trailing +// "-YYYYMMDD" release-date suffix Anthropic appends to a pinned release, +// capturing the id without it. The "claude" anchor is load-bearing: pricing +// looks every model up through this helper regardless of surface, and an +// operator may register a custom id with any shape at all, so an unanchored +// "-\d{8}$" would let "internal-llm-20250101" silently inherit the rate +// registered for "internal-llm". The anchor also covers the vendor-prefixed +// forms ("anthropic.claude-...", "us.anthropic.claude-..."). +var anthropicDatedModel = regexp.MustCompile(`(?i)^(.*claude.*)-\d{8}$`) + +// NormalizeAnthropicModel strips the trailing release-date suffix from a +// Claude model id, e.g. "claude-sonnet-4-5-20250929" -> "claude-sonnet-4-5", +// so a dated id a client pins matches the undated one the operator +// registered. Ids that are not Claude-family are returned untouched. +// Callers try the verbatim id first and fall back to this, so two dated +// releases of the same family stay distinct wherever both are registered +// explicitly. +func NormalizeAnthropicModel(modelID string) string { + return anthropicDatedModel.ReplaceAllString(modelID, "$1") +} + // NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id // (e.g. "claude-sonnet-4-5@20250929" -> "claude-sonnet-4-5") so it matches // the catalog/pricing key. Vertex publisher models are priced under their diff --git a/shared/llm/model_test.go b/shared/llm/model_test.go index 42f2e9ca5..5ce2ff497 100644 --- a/shared/llm/model_test.go +++ b/shared/llm/model_test.go @@ -34,3 +34,29 @@ func TestNormalizeVertexModel(t *testing.T) { require.Equal(t, want, NormalizeVertexModel(in), "normalize %q", in) } } + +func TestNormalizeAnthropicModel(t *testing.T) { + cases := map[string]string{ + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5", + "claude-3-5-haiku-20241022": "claude-3-5-haiku", + "claude-sonnet-5": "claude-sonnet-5", + "claude-opus-4-8": "claude-opus-4-8", + "anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5", + "anthropic.claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5", + "us.anthropic.claude-opus-4-8-20250101": "us.anthropic.claude-opus-4-8", + // Non-Claude ids must survive untouched even when they end in eight + // consecutive digits: an operator can register a custom model under + // any id, and pricing looks every one of them up through this helper. + "gpt-4o": "gpt-4o", + "gpt-4o-2024-08-06": "gpt-4o-2024-08-06", + "gpt-4o-20240806": "gpt-4o-20240806", + "internal-llm-20250101": "internal-llm-20250101", + "deepseek-r1-20250120": "deepseek-r1-20250120", + "Qwen/Qwen2.5-20250101": "Qwen/Qwen2.5-20250101", + "gemini-2-5-pro-20250101": "gemini-2-5-pro-20250101", + "": "", + } + for in, want := range cases { + require.Equal(t, want, NormalizeAnthropicModel(in), "normalize %q", in) + } +} From 7be45c2dd8b93434409037a05cc8bbdff8cc6a0a Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 23 Aug 2026 20:13:35 +0200 Subject: [PATCH 31/36] [proxy,management] Bound model discovery to the caller's own policies (#7239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [proxy,management] Bound model discovery to the caller's own policies GET /v1/models was bounded by the provider record's enumerated models, which is the right bound only while a single policy reaches a provider. Where two teams share one provider under different allowlists, every caller was offered the union — each model outside their own policy being a request the guardrail refuses a moment later. A gateway record enumerating nothing was worse: it offered the upstream's entire catalogue however narrow the policy was. Each route now carries one rule per authorising policy — its source groups and the models it permits — instead of a single flattened list. At request time the router keeps the rules whose groups intersect the caller's, unions their models, and intersects that with what the provider serves. nil and [] stay distinct end to end: a policy setting no allowlist reaches the router as nil and lifts the restriction for the groups it binds, while an enabled allowlist with no models arrives as [] and permits nothing. Collapsing them would let a listing that should offer nothing fall open to everything. The guardrail's own per-provider allowlist is untouched. It is a fail-closed backstop that cannot tell who is asking, so discovery is now narrower than the backstop rather than wider. --- e2e/agentnetwork/custom_pricing_test.go | 7 +- e2e/agentnetwork/discovery_live_test.go | 400 ++++++++++++++++++ .../discovery_multipolicy_test.go | 170 ++++++++ e2e/harness/client.go | 36 +- .../modules/agentnetwork/synthesizer.go | 70 ++- .../synthesizer_provider_allowlist_test.go | 73 ++++ .../middleware/builtin/llm_router/factory.go | 18 + .../builtin/llm_router/middleware.go | 103 ++++- .../builtin/llm_router/middleware_test.go | 141 ++++++ 9 files changed, 993 insertions(+), 25 deletions(-) create mode 100644 e2e/agentnetwork/discovery_live_test.go create mode 100644 e2e/agentnetwork/discovery_multipolicy_test.go diff --git a/e2e/agentnetwork/custom_pricing_test.go b/e2e/agentnetwork/custom_pricing_test.go index b3ca5028f..90e198d3d 100644 --- a/e2e/agentnetwork/custom_pricing_test.go +++ b/e2e/agentnetwork/custom_pricing_test.go @@ -174,7 +174,12 @@ func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID // accessLogIngestWindow is how long a single request's access-log row is given // to appear before the caller gives up on it. -const accessLogIngestWindow = 30 * time.Second +// accessLogIngestWindow bounds how long a row may take to appear after its +// request returned. The proxy streams each entry to management with a 10s send +// timeout of its own, so a request whose send hits one full timeout and is +// retried has not yet missed anything real — 30s left barely three send +// attempts of headroom and lost the race on a loaded runner. +const accessLogIngestWindow = 60 * time.Second // accessLogPollInterval is how long the lookup waits between pages. Ingest is // asynchronous, so the row lands somewhere inside the window rather than on diff --git a/e2e/agentnetwork/discovery_live_test.go b/e2e/agentnetwork/discovery_live_test.go new file mode 100644 index 000000000..321e751bf --- /dev/null +++ b/e2e/agentnetwork/discovery_live_test.go @@ -0,0 +1,400 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + sharedllm "github.com/netbirdio/netbird/shared/llm" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestLiveModelDiscovery drives model discovery against the REAL vendor +// endpoints — OpenAI, Anthropic, Bedrock and Vertex — rather than the mock. +// +// The mock upstream proves the filter's mechanics: it advertises ids we chose, +// so a listing narrowing to the ones we authorised is arithmetic we already +// controlled both sides of. What it cannot prove is that the filter survives +// contact with a real catalogue — ids we never enumerated, dated builds whose +// suffix the vendor picks, surfaces that answer a listing request with +// something other than a listing. That is what this covers, and it is the part +// a QA engineer would otherwise have to walk through by hand. +// +// One proxy serves every case. Each provider gets its own group, policy and +// client, because a model-less request matches exactly ONE route +// (matchModelless): with two providers authorised for the same caller, the +// listing would go to whichever won the tiebreak and the other would go +// untested. Group-scoping the caller makes each provider the only candidate +// for its own client. +func TestLiveModelDiscovery(t *testing.T) { + cases := liveDiscoveryCases() + if len(cases) == 0 { + t.Skip("no provider keys set; source ~/.llm-keys to run live model discovery") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + t.Logf("[discovery] live matrix: %s", strings.Join(caseNames(cases), ", ")) + + // Provision every provider, group and policy before the proxy starts: the + // proxy takes a configuration snapshot at connect time and does not + // reconcile provider changes made afterwards. + keys := make(map[string]string, len(cases)) + for i := range cases { + keys[cases[i].name] = provisionLiveDiscovery(t, ctx, &cases[i]) + } + + endpoint, firstIP, firstClient, px := connectClient(t, ctx, "disc-live", keys[cases[0].name]) + clients := map[string]*harness.Client{cases[0].name: firstClient} + ips := map[string]string{cases[0].name: firstIP} + for _, tc := range cases[1:] { + cl := joinClient(t, ctx, px, endpoint, keys[tc.name]) + ip, err := cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the %s client", tc.name) + clients[tc.name] = cl + ips[tc.name] = ip + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runLiveDiscoveryCase(t, ctx, tc, clients[tc.name], endpoint, ips[tc.name]) + }) + } +} + +// discoveryOutcome is what a discovery request must produce end to end. The +// three are genuinely different contracts, not degrees of success: only the +// first puts a bounded listing in front of the caller. +type discoveryOutcome int + +const ( + // outcomeFiltered: the proxy routes the request and bounds the response to + // what the caller may use. + outcomeFiltered discoveryOutcome = iota + // outcomeDenied: no provider of this shape can serve the surface, so the + // proxy refuses rather than rewriting the request onto an upstream that + // would 404 it. The caller gets a NetBird error, not a vendor one. + outcomeDenied + // outcomeUpstreamNoListing: the proxy routes the request to the configured + // upstream, and the vendor does not implement the endpoint there. Proxy + // side correct, product side a dead end — see the Bedrock case. + outcomeUpstreamNoListing +) + +// liveDiscoveryCase is one provider's discovery surface and what the proxy +// must make of it. +type liveDiscoveryCase struct { + name string + catalogID string + upstream string + apiKey string + + // path is the discovery endpoint the client calls. Not every surface uses + // /v1/models: Bedrock lists inference profiles instead. + path string + // headers the vendor requires on a bare GET (Anthropic versions its API + // through a header, and rejects a request without one). + headers []string + + // models the provider record enumerates. Empty models a gateway record, + // which enumerates nothing and claims everything. + models []string + // allowlist, when non-empty, is a guardrail narrowing the policy below the + // provider's own enumeration — the second of the two bounds discovery + // applies, and the only one a provider record alone cannot demonstrate. + allowlist []string + + // outcome is what this surface must produce end to end. + outcome discoveryOutcome + + // permitted is every id allowed to survive filtering, in the form the + // provider record registers it. A surviving id counts as permitted when it + // matches one of these outright or after Anthropic date-normalisation. + permitted []string + // wantHidden are ids the upstream is known to advertise and the bound must + // remove. Only set where we enumerate the model ourselves, so the + // expectation cannot rot when a vendor changes its catalogue. + wantHidden []string +} + +// liveDiscoveryCases builds the matrix from whichever provider credentials are +// present, mirroring availableProviders' env-var gating so a partial key set +// still yields partial coverage. +func liveDiscoveryCases() []liveDiscoveryCase { + var cases []liveDiscoveryCase + + // OpenAI enumerates TWO real models and the policy permits one. That is + // the only case here where both bounds are observable at once: the + // upstream advertises dozens of ids, the provider record cuts them to two, + // and the guardrail cuts those to one. + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, + path: "/v1/models", + models: []string{"gpt-4o-mini", "gpt-4o"}, + allowlist: []string{"gpt-4o-mini"}, + outcome: outcomeFiltered, + permitted: []string{"gpt-4o-mini"}, + wantHidden: []string{"gpt-4o"}, + }) + } + + // Anthropic is the surface Claude Code actually calls. Its listing returns + // DATED build ids (claude-haiku-4-5-20251001) while the provider record + // registers the undated id, so this is the case that proves the filter's + // date-normalisation against ids the vendor chose rather than ids we wrote. + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, + path: "/v1/models", + headers: []string{"anthropic-version: 2023-06-01"}, + models: []string{"claude-haiku-4-5"}, + outcome: outcomeFiltered, + permitted: []string{"claude-haiku-4-5"}, + }) + } + + // Bedrock lists inference profiles, not models: matchModelless routes + // /inference-profiles to a Bedrock route and refuses /v1/models for one. + // + // The request reaches AWS and AWS refuses it — bedrock-runtime answers + // , because ListInferenceProfiles is a CONTROL + // PLANE operation served by bedrock..amazonaws.com, not the runtime + // host. A provider record carries one upstream and it has to be the runtime + // host for InvokeModel to work, so no Bedrock record can serve a listing as + // the model stands today. + // + // The mock upstream hides this entirely: it answers /inference-profiles on + // the same listener as everything else, so the routing test passes there + // while the real endpoint 404s. That is the whole reason this file exists, + // so the case is kept, asserting what actually happens. + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "eu-central-1" + } + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-sonnet-4-6" + } + cases = append(cases, liveDiscoveryCase{ + name: "bedrock", catalogID: "bedrock_api", + upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, + path: "/inference-profiles", + models: []string{sharedllm.NormalizeAnthropicModel(strings.TrimPrefix(model, "global."))}, + outcome: outcomeUpstreamNoListing, + }) + } + + // Vertex carries the model in the rawPredict path and serves no listing + // endpoint at all, so the proxy must refuse discovery rather than rewrite + // it onto an upstream that would 404. + if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" { + if project := os.Getenv("GOOGLE_VERTEX_PROJECT"); project != "" { + region := os.Getenv("GOOGLE_VERTEX_REGION") + if region == "" { + region = "global" + } + host := "aiplatform.googleapis.com" + if region != "global" { + host = region + "-aiplatform.googleapis.com" + } + cases = append(cases, liveDiscoveryCase{ + name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host, + apiKey: "keyfile::" + sa, + path: "/v1/models", + outcome: outcomeDenied, + }) + } + } + + return cases +} + +// provisionLiveDiscovery creates the group, provider, optional guardrail and +// policy for one case, and returns the setup key a client joins that group +// with. Scoping each provider to its own group is what keeps it the only +// candidate for its own client's model-less request. +func provisionLiveDiscovery(t *testing.T, ctx context.Context, tc *liveDiscoveryCase) string { + t.Helper() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-live-" + tc.name}) + require.NoError(t, err, "create group for %s", tc.name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-disc-live-" + tc.name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key for %s", tc.name) + require.NotEmpty(t, sk.Key, "setup key plaintext for %s", tc.name) + + req := api.AgentNetworkProviderRequest{ + Name: "e2e-disc-live-" + tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: tc.upstream, + ApiKey: &tc.apiKey, + Enabled: ptr(true), + } + if len(tc.models) > 0 { + models := make([]api.AgentNetworkProviderModel, 0, len(tc.models)) + for _, id := range tc.models { + models = append(models, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.002}) + } + req.Models = &models + } + prov, err := srv.CreateProvider(ctx, req) + require.NoError(t, err, "create provider %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + polReq := api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-live-" + tc.name, + Enabled: ptr(true), + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + } + if len(tc.allowlist) > 0 { + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-disc-live-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = tc.allowlist + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail for %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + polReq.GuardrailIds = &[]string{g.Id} + } + pol, err := srv.CreatePolicy(ctx, polReq) + require.NoError(t, err, "create policy for %s", tc.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + return sk.Key +} + +// runLiveDiscoveryCase issues the discovery request and reports everything the +// vendor said before asserting on any of it. The log is the point on the first +// run: a live catalogue is the one input we do not control, so a failure has to +// arrive with the response that caused it rather than just a count. +func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCase, cl *harness.Client, endpoint, proxyIP string) { + t.Helper() + + // A single request is enough for the two non-listing outcomes, and retrying + // them would burn the retry window waiting for a status that is never + // coming. + if tc.outcome != outcomeFiltered { + code, body, err := cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + require.NoError(t, err, "request must reach the proxy") + t.Logf("[discovery] %s GET %s -> %d; body: %s", tc.name, tc.path, code, truncate(body, 2000)) + assert.NotEqual(t, 200, code, + "%s serves no bounded listing, so a 200 here would mean the caller was handed a picker nothing narrows; body: %s", + tc.name, truncate(body, 2000)) + + // Which side refused is the whole distinction between these two + // outcomes, and a NetBird error is the thing that tells them apart: the + // middleware chain stamps its own name on anything it generates. + if tc.outcome == outcomeDenied { + assert.True(t, isProxyError(body), + "%s serves no listing endpoint at all, so the proxy must refuse the request itself rather than forward it to an upstream that would answer for us; body: %s", + tc.name, truncate(body, 2000)) + return + } + assert.False(t, isProxyError(body), + "%s discovery must be routed to the configured upstream and refused by the vendor, not blocked by the proxy; body: %s", + tc.name, truncate(body, 2000)) + return + } + + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + }, 200) + t.Logf("[discovery] %s GET %s -> %d; body: %s", tc.name, tc.path, code, truncate(body, 4000)) + require.Equal(t, 200, code, "%s discovery must be served; body: %s", tc.name, truncate(body, 2000)) + + ids, ok := listingIDs(body) + require.Truef(t, ok, + "%s answered discovery with something other than a {\"data\":[{\"id\":…}]} listing, which the filter forwards untouched — the caller would get an unbounded picker; body: %s", + tc.name, truncate(body, 2000)) + sort.Strings(ids) + t.Logf("[discovery] %s: %d ids after filtering: %s", tc.name, len(ids), strings.Join(ids, ", ")) + + require.NotEmpty(t, ids, "%s filtered the listing down to nothing; the caller would see an empty picker", tc.name) + + permitted := make(map[string]struct{}, len(tc.permitted)*2) + for _, id := range tc.permitted { + permitted[id] = struct{}{} + permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{} + } + for _, id := range ids { + _, direct := permitted[id] + _, normalised := permitted[sharedllm.NormalizeAnthropicModel(id)] + assert.Truef(t, direct || normalised, + "%s offered %q, which no policy on this route permits — every entry the picker shows must be a request the guardrail would allow", tc.name, id) + } + for _, hidden := range tc.wantHidden { + assert.NotContainsf(t, ids, hidden, + "%s offered %q, which the provider enumerates but the policy does not permit", tc.name, hidden) + } +} + +// isProxyError reports whether a response body was generated by the middleware +// chain rather than forwarded from a vendor. Every chain-generated error names +// the middleware that raised it, which no upstream's error body does — so this +// separates "the proxy refused" from "the proxy routed it and the vendor +// refused", the two failures that otherwise look alike from the client side. +func isProxyError(body string) bool { + return strings.Contains(body, `"middleware":`) +} + +// listingIDs pulls the model ids out of a listing response. ok is false when +// the body is not the {"data":[{"id":…}]} shape the filter recognises. +func listingIDs(body string) ([]string, bool) { + var doc struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return nil, false + } + if doc.Data == nil { + return nil, false + } + ids := make([]string, 0, len(doc.Data)) + for _, entry := range doc.Data { + ids = append(ids, entry.ID) + } + return ids, true +} + +func caseNames(cases []liveDiscoveryCase) []string { + names := make([]string, 0, len(cases)) + for _, c := range cases { + names = append(names, c.name) + } + return names +} + +// truncate bounds a logged response body. A live catalogue can run to tens of +// kilobytes, and the useful part is the front. +func truncate(s string, limit int) string { + if len(s) <= limit { + return s + } + return s[:limit] + "… (" + strconv.Itoa(len(s)-limit) + " more bytes)" +} diff --git a/e2e/agentnetwork/discovery_multipolicy_test.go b/e2e/agentnetwork/discovery_multipolicy_test.go new file mode 100644 index 000000000..447c1314c --- /dev/null +++ b/e2e/agentnetwork/discovery_multipolicy_test.go @@ -0,0 +1,170 @@ +//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" +) + +// TestDiscoveryBoundToCallersPolicies covers a model listing on a provider two +// teams reach under different allowlists. +// +// Bounding the listing by the provider's enumerated models alone is not enough +// once more than one policy is in play: the caller would be offered every model +// any team may use, and each one outside their own policy is a request the +// guardrail refuses a moment later — the empty-or-wrong picker this endpoint +// exists to avoid, just moved one level up. +// +// The client joins the main group only. Both models are enumerated by the same +// provider and both are advertised by the upstream, so a listing that leaked +// the other team's model would visibly contain it. +func TestDiscoveryBoundToCallersPolicies(t *testing.T) { + 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()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-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-disc-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + mkKey := func(name, groupID string) string { + sk, kerr := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{groupID}, + Ephemeral: &ephemeral, + }) + require.NoError(t, kerr, "mint setup key %s", name) + require.NotEmpty(t, sk.Key, "setup key plaintext") + return sk.Key + } + // One client per group. The second is what makes the first assertion mean + // something: without a client that DOES see the other team's model, its + // absence from the main client's listing could equally be a policy that + // never propagated. + keyMain := mkKey("e2e-disc-mp-main-client", grpMain.Id) + keyOther := mkKey("e2e-disc-mp-other-client", grpOther.Id) + + // One provider enumerating both models the upstream advertises, so the + // listing is narrowed by policy rather than by what the provider serves. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-disc-mp", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: harness.VLLMUnlistedModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.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-disc-mp-main", harness.VLLMModel) + gOther := mkGuardrail("e2e-disc-mp-other", harness.VLLMUnlistedModel) + + enabled := true + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // The other team's policy, on the same provider, permitting the model the + // client must never be offered. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + endpoint, proxyIP, clMain, px := connectClient(t, ctx, "disc-mp", keyMain) + clOther := joinClient(t, ctx, px, endpoint, keyOther) + + listing := func(t *testing.T, cl *harness.Client, ip string) string { + t.Helper() + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, ip, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, "discovery must be served; body: %s", body) + return body + } + + otherIP, err := clOther.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the other client") + + // The other team's client first: seeing its own model proves polOther is + // live, so the main client's listing is narrowed by policy scoping rather + // than by the other policy having failed to apply at all. + otherBody := listing(t, clOther, otherIP) + assert.Contains(t, otherBody, harness.VLLMUnlistedModel, + "the other group's policy must be in force, or this test proves nothing") + assert.NotContains(t, otherBody, harness.VLLMModel, + "and it must not be offered the main group's model either — isolation runs both ways") + + mainBody := listing(t, clMain, proxyIP) + assert.Contains(t, mainBody, harness.VLLMModel, + "the model the caller's own policy permits must reach the picker") + assert.NotContains(t, mainBody, harness.VLLMUnlistedModel, + "a model only another group's policy permits must not be offered to this caller") +} + +// joinClient starts a second tunnel client against an already-running proxy, so +// a test can drive the same endpoint as two different group memberships without +// paying for a second proxy. +func joinClient(t *testing.T, ctx context.Context, px *harness.Proxy, endpoint, setupKey string) *harness.Client { + t.Helper() + + cl, err := harness.StartClient(ctx, srv, setupKey) + require.NoError(t, err, "start second client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "second client must connect to management") + _, err = cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "second client could not resolve the endpoint") + // Guarded rather than passed straight to require: px.Logs pulls the whole + // proxy container log, which is only worth fetching when the wait failed. + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + require.NoError(t, err, "second client did not see the proxy peer\n=== proxy logs ===\n%s", + px.Logs(context.Background())) + } + return cl +} diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 9e9e7b34a..73931027d 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -200,12 +200,18 @@ func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want st 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 + // curlExitCouldNotConnect is curl's exit code for a connection that never + // established. The probe exists to WAKE the lazy proxy peer, so the first + // attempt legitimately arrives before WireGuard has brought the tunnel up + // and fails here — which is propagation, exactly like an early NXDOMAIN, + // and belongs inside the retry window rather than failing the test outright. + curlExitCouldNotConnect = 7 + // endpointProbeRetryWindow bounds retries of the transient failures above: the synthesized zone and the tunnel both land a beat after management connects. Still failing after this window is a real failure. + endpointProbeRetryWindow = 30 * time.Second + endpointProbeRetryInterval = 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. +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; DNS and connect failures retry, within endpointProbeRetryWindow. Returns the connected IP for --resolve pinning. func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { args := []string{ "run", "--rm", @@ -216,7 +222,7 @@ func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, "-w", "%{remote_ip}", "https://" + endpoint + "/", } - deadline := time.Now().Add(dnsProbeRetryWindow) + deadline := time.Now().Add(endpointProbeRetryWindow) for { cmd := exec.CommandContext(ctx, "docker", args...) var stdout, stderr strings.Builder @@ -232,21 +238,29 @@ func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, } var exitErr *exec.ExitError - if !errors.As(err, &exitErr) || exitErr.ExitCode() != curlExitCouldNotResolve { + if !errors.As(err, &exitErr) || !isTransientProbeExit(exitErr.ExitCode()) { 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 + probeErr := fmt.Errorf("endpoint %s not reachable yet: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < endpointProbeRetryInterval { + return "", probeErr } select { case <-ctx.Done(): - return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err()) - case <-time.After(dnsProbeRetryInterval): + return "", fmt.Errorf("%w (%w)", probeErr, ctx.Err()) + case <-time.After(endpointProbeRetryInterval): } } } +// isTransientProbeExit reports whether a curl exit code describes a state the +// endpoint is expected to pass THROUGH on its way up, rather than a settled +// failure. Anything else — TLS refusal, a protocol error, a bad argument — +// would still be failing after the retry window, so it fails immediately. +func isTransientProbeExit(code int) bool { + return code == curlExitCouldNotResolve || code == curlExitCouldNotConnect +} + // Wire shapes for Chat. const ( // WireChat is the OpenAI-compatible /v1/chat/completions shape. diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 3fd92be96..76944698e 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -211,7 +211,19 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([ groupIndex := indexProviderGroups(enabledPolicies) - routerCfgJSON, err := buildRouterConfigJSON(enabledProviders, groupIndex) + // The proxy guardrail is a per-provider fail-closed backstop; the + // authoritative per-policy/group decision is management's + // SelectPolicyForRequest. A provider lands in that map only when every + // authorising policy restricts models. + providerAllowlists := buildProviderAllowlists(enabledPolicies, guardrailsByID) + + // Discovery gets the finer view: per policy rather than flattened per + // provider, so a listing can be bounded to what the calling groups may + // actually use instead of the union across everyone who reaches the + // provider. + modelPolicies := buildModelPolicies(enabledPolicies, guardrailsByID) + + routerCfgJSON, err := buildRouterConfigJSON(enabledProviders, groupIndex, modelPolicies) if err != nil { return nil, err } @@ -228,11 +240,6 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([ mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID) applyAccountCollectionControls(&mergedGuardrails, settings) - // 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 @@ -351,6 +358,11 @@ type routerProviderRoute struct { AuthHeaderName string `json:"auth_header_name"` AuthHeaderValue string `json:"auth_header_value"` AllowedGroupIDs []string `json:"allowed_group_ids,omitempty"` + // ModelPolicies is one entry per enabled policy authorising this provider, + // carrying that policy's source groups and the models it permits. The + // router bounds a model listing with it, so a provider two groups reach + // under different allowlists offers each only its own. + ModelPolicies []routerModelPolicy `json:"model_policies,omitempty"` // Vertex marks a Google Vertex AI provider, whose requests carry the // model in the URL path. The router selects it by path, bypassing the // model/vendor table. @@ -422,7 +434,7 @@ func indexProviderGroups(policies []*types.Policy) map[string][]string { // path-prefix tiebreak. Providers no enabled policy authorises // (orphans) are intentionally OMITTED so the router never observes a // route with an empty ACL. -func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) { +func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]string, modelPolicies map[string][]routerModelPolicy) ([]byte, error) { cfg := routerConfig{Providers: make([]routerProviderRoute, 0, len(providers))} for _, p := range providers { groups, hasPolicy := groupIndex[p.ID] @@ -449,6 +461,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][] AuthHeaderName: headerName, AuthHeaderValue: headerValue, AllowedGroupIDs: groups, + ModelPolicies: modelPolicies[p.ID], Vertex: catalog.IsVertexPathStyle(p.ProviderID), Bedrock: catalog.IsBedrockPathStyle(p.ProviderID), GCPServiceAccountKeyB64: gcpSAKeyB64, @@ -1098,3 +1111,46 @@ func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails) { } } } + +// routerModelPolicy mirrors the router's ModelPolicyRule: one authorising +// policy's source groups plus the models it permits. Models is nil for a +// policy that sets no model allowlist, which lifts the restriction for the +// groups it binds — so nil and empty must survive the round trip distinctly. +type routerModelPolicy struct { + GroupIDs []string `json:"group_ids"` + Models []string `json:"models"` +} + +// buildModelPolicies indexes, per provider, one rule for each enabled policy +// authorising it: the policy's source groups and the models its guardrail +// permits. +// +// This is deliberately finer than buildProviderAllowlists, which flattens the +// same inputs into one list per provider for the proxy's fail-closed guardrail. +// A flattened list cannot answer "what may THIS caller see", so a provider two +// teams reach under different allowlists would offer each team the other's +// models — a picker full of entries the next request refuses. Keeping the +// source groups alongside the models lets the router answer it at request time, +// where it knows the caller's groups. +func buildModelPolicies(policies []*types.Policy, byID map[string]*types.Guardrail) map[string][]routerModelPolicy { + out := make(map[string][]routerModelPolicy) + for _, p := range policies { + if p == nil || len(p.SourceGroups) == 0 { + continue + } + restricted, models := policyModelAllowlist(p, byID) + rule := routerModelPolicy{GroupIDs: append([]string(nil), p.SourceGroups...)} + if restricted { + // Never nil when restricted: an allowlist permitting nothing must + // stay distinguishable from no allowlist at all. + rule.Models = append([]string{}, models...) + } + for _, providerID := range p.DestinationProviderIDs { + if providerID == "" { + continue + } + out[providerID] = append(out[providerID], rule) + } + } + return out +} diff --git a/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go index 2cfc0db8c..a27cd2ae4 100644 --- a/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" ) @@ -93,3 +94,75 @@ func TestBuildProviderAllowlists(t *testing.T) { "an enabled-but-empty allowlist is restricted with an empty set, not unrestricted") }) } + +// policyForGroups builds an enabled policy binding the given source groups to +// the given providers under an optional guardrail. +func policyForGroups(id string, groups []string, guardrailIDs []string, providerIDs ...string) *types.Policy { + return &types.Policy{ + ID: id, + Enabled: true, + SourceGroups: groups, + DestinationProviderIDs: providerIDs, + GuardrailIDs: guardrailIDs, + } +} + +// TestBuildModelPolicies covers the finer index discovery needs. Where +// buildProviderAllowlists flattens every authorising policy into one list per +// provider — enough for a fail-closed backstop, but blind to who is asking — +// this keeps each policy's source groups beside its models so the router can +// bound a listing to the calling groups. +func TestBuildModelPolicies(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("each policy keeps its own groups and models", func(t *testing.T) { + policies := []*types.Policy{ + policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"), + policyForGroups("p2", []string{"grp-sales"}, []string{"g-opus"}, "prov-x"), + } + got := buildModelPolicies(policies, byID) + assert.Equal(t, []routerModelPolicy{ + {GroupIDs: []string{"grp-eng"}, Models: []string{"gpt-4o"}}, + {GroupIDs: []string{"grp-sales"}, Models: []string{"claude-opus-4"}}, + }, got["prov-x"], + "the two policies must stay separable so neither group is offered the other's models") + }) + + t.Run("an unrestricted policy carries nil models", func(t *testing.T) { + policies := []*types.Policy{ + policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"), + policyForGroups("p2", []string{"grp-admin"}, nil, "prov-x"), + } + got := buildModelPolicies(policies, byID) + assert.Nil(t, got["prov-x"][1].Models, + "no allowlist must reach the router as nil, which lifts the restriction for its groups") + }) + + t.Run("a disabled allowlist is not a restriction", func(t *testing.T) { + policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-disabled"}, "prov-x")} + got := buildModelPolicies(policies, byID) + assert.Nil(t, got["prov-x"][0].Models, + "a guardrail with the allowlist check off restricts nothing") + }) + + t.Run("an enabled allowlist with no models permits nothing", func(t *testing.T) { + byIDEmpty := map[string]*types.Guardrail{ + "g-empty": {ID: "g-empty", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true}}}, + } + policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-empty"}, "prov-x")} + got := buildModelPolicies(policies, byIDEmpty) + require.NotNil(t, got["prov-x"][0].Models, + "an empty allowlist must not arrive as nil — that would read as unrestricted") + assert.Empty(t, got["prov-x"][0].Models) + }) + + t.Run("a policy binding no groups is skipped", func(t *testing.T) { + policies := []*types.Policy{policyForGroups("p1", nil, []string{"g-4o"}, "prov-x")} + assert.Empty(t, buildModelPolicies(policies, byID), + "a policy with no source groups authorises nobody, so it bounds nobody's listing") + }) +} diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go index 938a23ebe..ae3d44a40 100644 --- a/proxy/internal/middleware/builtin/llm_router/factory.go +++ b/proxy/internal/middleware/builtin/llm_router/factory.go @@ -44,6 +44,12 @@ type ProviderRoute struct { AuthHeaderName string `json:"auth_header_name"` AuthHeaderValue string `json:"auth_header_value"` AllowedGroupIDs []string `json:"allowed_group_ids"` + // ModelPolicies carries, per authorising policy, the source groups it + // binds and the models it permits. The router uses it to bound a model + // listing to what THIS caller may use: a provider reachable by two groups + // under different allowlists must not offer either group the other's + // models. Empty means no policy restricts models on this route. + ModelPolicies []ModelPolicyRule `json:"model_policies,omitempty"` // Vertex marks a Google Vertex AI provider. Vertex requests carry the // model in the URL path, so the router selects this route by path // (isVertexPath) and bypasses the model/vendor table entirely. @@ -65,6 +71,18 @@ type ProviderRoute struct { SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` } +// ModelPolicyRule is one authorising policy's contribution to what a caller +// may use on a route: the source groups it binds, and the models it permits. +// +// Models is nil when the policy sets no model allowlist — an unrestricted +// policy, which lifts the restriction for the groups it binds. That is why +// nil and empty must stay distinct: an empty list is a guardrail that permits +// nothing, and collapsing the two would let a listing fail open. +type ModelPolicyRule struct { + GroupIDs []string `json:"group_ids"` + Models []string `json:"models"` +} + // Config is the on-wire configuration accepted by the factory. An // empty Providers slice yields a router that denies every request as // not-routable; the synthesiser is responsible for stamping the diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index e6ad332fc..01981666c 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -242,12 +242,13 @@ func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix { stripBedrockNamespace(out) } - // A route that enumerates its models bounds what the caller may use, - // so the picker must not offer the rest: every entry outside the list - // is a request the chain will deny. - if reqPath == modelListingPath && len(route.Models) > 0 && - out.Mutations != nil && out.Mutations.RewriteUpstream != nil { - out.Mutations.RewriteUpstream.DiscoveryModels = append([]string(nil), route.Models...) + // What the caller may actually use bounds what the picker may offer: + // every entry outside it is a request the chain will deny a moment + // later. + if reqPath == modelListingPath && out.Mutations != nil && out.Mutations.RewriteUpstream != nil { + if models, bounded := discoverableModels(route, userGroups); bounded { + out.Mutations.RewriteUpstream.DiscoveryModels = models + } } return out case matchOutcomeUnauthorised: @@ -271,6 +272,96 @@ func isNonInferenceMethod(method string) bool { return method == http.MethodGet || method == http.MethodHead } +// discoverableModels returns the model ids a caller in userGroups may actually +// use on this route, and whether the listing should be bounded to them at all. +// +// Two things narrow a listing, and both must apply or the picker offers models +// the very next request refuses: +// +// - the provider's own enumerated models, when it lists any (a gateway record +// enumerates nothing and claims everything); +// - the model allowlists of the policies that authorise THIS caller. A +// provider reachable by two groups under different allowlists must not +// offer either group the other's models, which is why the rules carry their +// source groups rather than arriving pre-flattened. +// +// A policy that sets no allowlist lifts the restriction for the groups it +// binds, so a caller holding one unrestricted policy sees the provider's full +// list. bounded is false when nothing narrows the listing — an unrestricted +// caller on a route that enumerates nothing — in which case the upstream's own +// answer passes through untouched. +func discoverableModels(route ProviderRoute, userGroups []string) ([]string, bool) { + permitted, restricted := policyPermittedModels(route, userGroups) + + switch { + case !restricted && len(route.Models) == 0: + return nil, false + case !restricted: + return append([]string(nil), route.Models...), true + case len(route.Models) == 0: + // A gateway record enumerates nothing, so the allowlist is the whole + // bound — previously such a record offered the upstream's entire + // catalogue however narrow the policy was. + return sortedModels(permitted), true + } + + // Both bound: only what the provider serves and the policy permits. + intersection := make(map[string]struct{}, len(route.Models)) + for _, m := range route.Models { + if _, ok := permitted[m]; ok { + intersection[m] = struct{}{} + } + } + return sortedModels(intersection), true +} + +// policyPermittedModels folds the rules whose groups intersect the caller's +// into the set of models they permit. restricted is false when the caller +// holds at least one authorising policy that sets no allowlist, or when no +// rule binds them at all. +func policyPermittedModels(route ProviderRoute, userGroups []string) (map[string]struct{}, bool) { + permitted := make(map[string]struct{}) + restricted := false + for _, rule := range route.ModelPolicies { + if !groupsIntersect(rule.GroupIDs, userGroups) { + continue + } + if rule.Models == nil { + // An unrestricted policy the caller holds lifts the restriction + // entirely, whatever the others say. + return nil, false + } + restricted = true + for _, m := range rule.Models { + permitted[m] = struct{}{} + } + } + return permitted, restricted +} + +// groupsIntersect reports whether the two group-id sets share a member. +func groupsIntersect(a, b []string) bool { + for _, x := range a { + for _, y := range b { + if x == y { + return true + } + } + } + return false +} + +// sortedModels flattens a model set into a stable slice so the bound the proxy +// applies — and any test asserting on it — does not depend on map order. +func sortedModels(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for m := range set { + out = append(out, m) + } + sort.Strings(out) + return out +} + // markNonInference tags an allow as a request that spends no tokens, so the // limit check skips the management pre-flight it would charge nothing against. func markNonInference(out *middleware.Output) { diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go index 336cdb9fe..5a1d32480 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -1145,3 +1145,144 @@ func TestRouter_PinnedDatedModelStaysDistinct(t *testing.T) { "declaration order must not decide between two deliberately pinned builds") }) } + +// TestRouter_DiscoveryBoundToCallersPolicies pins that a model listing is +// bounded by the policies that authorise the caller, not by the union across +// everyone who can reach the provider. Two teams sharing one provider record +// under different allowlists is the case that makes the difference visible: a +// flattened per-provider list would offer each team the other's models, and +// every one of those entries is a request the guardrail then refuses. +func TestRouter_DiscoveryBoundToCallersPolicies(t *testing.T) { + const ( + eng = "grp-eng" + sales = "grp-sales" + ) + route := ProviderRoute{ + ID: "shared-gateway", + Models: []string{"claude-sonnet-5", "claude-haiku-4-5", "gpt-4o"}, + AllowedGroupIDs: []string{eng, sales}, + UpstreamScheme: "https", + UpstreamHost: "gateway.example.com", + ModelPolicies: []ModelPolicyRule{ + {GroupIDs: []string{eng}, Models: []string{"claude-sonnet-5"}}, + {GroupIDs: []string{sales}, Models: []string{"gpt-4o"}}, + }, + } + + listingFor := func(t *testing.T, group string) []string { + t.Helper() + mw := New(Config{Providers: []ProviderRoute{route}}) + in := newModellessInput(modelListingPath) + in.UserGroups = []string{group} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + return out.Mutations.RewriteUpstream.DiscoveryModels + } + + t.Run("each group sees only its own policy's models", func(t *testing.T) { + assert.Equal(t, []string{"claude-sonnet-5"}, listingFor(t, eng), + "engineering must not be offered the model only sales may use") + assert.Equal(t, []string{"gpt-4o"}, listingFor(t, sales), + "sales must not be offered the model only engineering may use") + }) + + t.Run("a model no policy allows is offered to nobody", func(t *testing.T) { + for _, group := range []string{eng, sales} { + assert.NotContains(t, listingFor(t, group), "claude-haiku-4-5", + "the provider serves it, but no policy permits it") + } + }) +} + +// TestRouter_DiscoveryUnrestrictedPolicy covers the lifting rule: a caller +// holding one policy without a model allowlist sees everything the provider +// enumerates, whatever the other policies say. +func TestRouter_DiscoveryUnrestrictedPolicy(t *testing.T) { + const ( + eng = "grp-eng" + admin = "grp-admin" + ) + route := ProviderRoute{ + ID: "shared-gateway", + Models: []string{"claude-sonnet-5", "gpt-4o"}, + AllowedGroupIDs: []string{eng, admin}, + UpstreamScheme: "https", + UpstreamHost: "gateway.example.com", + ModelPolicies: []ModelPolicyRule{ + {GroupIDs: []string{eng}, Models: []string{"claude-sonnet-5"}}, + // nil Models: a policy that sets no allowlist at all. + {GroupIDs: []string{admin}}, + }, + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + in := newModellessInput(modelListingPath) + in.UserGroups = []string{eng, admin} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.ElementsMatch(t, []string{"claude-sonnet-5", "gpt-4o"}, + out.Mutations.RewriteUpstream.DiscoveryModels, + "an unrestricted policy the caller holds lifts the restriction") +} + +// TestRouter_DiscoveryOnGatewayRecord covers a record that enumerates no +// models. It previously offered the upstream's whole catalogue however narrow +// the policy was, because there was nothing to intersect against; the policy +// allowlist is now the bound on its own. +func TestRouter_DiscoveryOnGatewayRecord(t *testing.T) { + const eng = "grp-eng" + base := ProviderRoute{ + ID: "litellm", + AllowedGroupIDs: []string{eng}, + UpstreamScheme: "https", + UpstreamHost: "litellm.internal", + } + + t.Run("a policy allowlist bounds it", func(t *testing.T) { + route := base + route.ModelPolicies = []ModelPolicyRule{{GroupIDs: []string{eng}, Models: []string{"gpt-4o"}}} + mw := New(Config{Providers: []ProviderRoute{route}}) + + in := newModellessInput(modelListingPath) + in.UserGroups = []string{eng} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, []string{"gpt-4o"}, out.Mutations.RewriteUpstream.DiscoveryModels, + "a catch-all record must still be bounded by what policy permits") + }) + + t.Run("an allowlist permitting nothing offers nothing", func(t *testing.T) { + route := base + route.ModelPolicies = []ModelPolicyRule{{GroupIDs: []string{eng}, Models: []string{}}} + mw := New(Config{Providers: []ProviderRoute{route}}) + + in := newModellessInput(modelListingPath) + in.UserGroups = []string{eng} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels, + "an empty allowlist permits nothing, and must not be read as unrestricted") + }) + + t.Run("no policy restriction leaves the listing alone", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{base}}) + + in := newModellessInput(modelListingPath) + in.UserGroups = []string{eng} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Nil(t, out.Mutations.RewriteUpstream.DiscoveryModels, + "nothing narrows the listing, so the upstream's own answer passes through") + }) +} From 5e88d3f87afd6debab57e149bae754a3c46dcb75 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 23 Aug 2026 20:21:25 +0200 Subject: [PATCH 32/36] [management] Offer a provider's live model list in the config form (#7246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [management] Offer a provider's live model list in the config form Adds POST /api/agent-network/catalog/providers/models, which asks a vendor which models an operator's own credential can actually reach, so the provider form can offer a live list instead of only the compiled-in catalog. The catalog goes stale, and it cannot see an account: which OpenAI models an org is entitled to, which Bedrock inference profiles an account and region hold, which Vertex models a project has enabled. The endpoints, auth headers and response shapes come from probing the live APIs (#7244); each vendor invented its own envelope and none can be guessed from the request. Bedrock shaped the design: its listing lives on the control plane while inference must go to the runtime host, so Discovery carries its own host rather than reusing the record's upstream, and profile ids are taken verbatim because the region prefix is what AWS requires at invoke time. A caller supplies either the key they are typing or the id of a saved record whose stored credential is reused — never both, since accepting both would run an arbitrary credential under the identity of a record the caller may only be permitted to read. Gated on Create rather than Read, because this spends the operator's credential against a third party. Management has not made outbound calls on an operator's behalf before and it holds a credential for every provider, so every resolved address must be public — covering loopback, RFC1918, the cloud metadata address and NetBird's own 100.64/10 range — and redirects are not followed, since a redirect moves the request to a host the check never saw. The vendor is authoritative for the id; the catalog stays authoritative for pricing. A discovered model the shipped table cannot price returns pricing_known: false so the operator must set rates rather than being registered at a silent zero. --- .../modules/agentnetwork/catalog/catalog.go | 100 +++- .../handlers/model_discovery_handler_test.go | 178 +++++++ .../handlers/providers_handler.go | 95 ++++ .../internals/modules/agentnetwork/manager.go | 48 ++ .../agentnetwork/modeldiscovery/discovery.go | 469 +++++++++++++++++ .../modeldiscovery/discovery_test.go | 496 ++++++++++++++++++ .../agentnetwork/modeldiscovery/parse.go | 134 +++++ shared/management/http/api/openapi.yml | 114 ++++ shared/management/http/api/types.gen.go | 51 ++ 9 files changed, 1681 insertions(+), 4 deletions(-) create mode 100644 management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go create mode 100644 management/internals/modules/agentnetwork/modeldiscovery/discovery.go create mode 100644 management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go create mode 100644 management/internals/modules/agentnetwork/modeldiscovery/parse.go diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index c534f9a85..3c7b995e5 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -113,8 +113,61 @@ type Provider struct { // upstream provider + credentials on Portkey's hosted side). ExtraHeaders []ExtraHeader Models []Model + // Discovery, when non-nil, describes how to ask this vendor which + // models the operator's own credential can actually reach, so the + // provider form can offer a live list instead of only the hand-curated + // Models above. Nil for entries with no listing endpoint (gateways + // vary too much) — those keep free-text entry. + Discovery *Discovery } +// ListingShape names the response envelope a vendor returns its model +// listing in. Every vendor invented its own, and none of them can be +// guessed from the request, so the catalog states it. +type ListingShape string + +const ( + // ShapeOpenAIData is {"data":[{"id":…}]} — OpenAI, and Anthropic, which + // adopted the same envelope. + ShapeOpenAIData ListingShape = "openai_data" + // ShapeBedrockInferenceProfiles is + // {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. The ids carry + // the region prefix that makes them invocable, which is exactly what an + // operator cannot reconstruct by hand. + ShapeBedrockInferenceProfiles ListingShape = "bedrock_inference_profiles" + // ShapeVertexPublisherModels is {"publisherModels":[{"name":…}]}, where + // name is a resource path and the invocable id is its last segment joined + // to a separate versionId field. + ShapeVertexPublisherModels ListingShape = "vertex_publisher_models" +) + +// Discovery describes one vendor's model-listing endpoint. +// +// Host is deliberately separate from the provider record's upstream URL: +// Bedrock serves listings from the control plane (bedrock.) while +// inference must go to the runtime host (bedrock-runtime.), so the +// two cannot be the same value. Empty Host means "use the record's own +// upstream", which is right for every vendor that serves both from one host. +// +// The regionPlaceholder in Host is substituted from the provider record's +// region. Deriving the discovery host from the catalog rather than accepting +// one from the caller is also what keeps this from being an open proxy: the +// only hosts management will dial are the ones written here. +type Discovery struct { + Host string + Path string + Query string + Shape ListingShape + // Headers are static headers the vendor requires beyond the credential + // (Anthropic versions its API through one and rejects a request without + // it). The auth header itself comes from AuthHeaderName/Template. + Headers map[string]string +} + +// RegionPlaceholder is replaced in Discovery.Host by the provider record's +// configured region. +const RegionPlaceholder = "" + // ExtraHeader names a single optional per-provider routing/config // header. Catalog declares N of these per provider type; the operator // fills any subset on the provider record (see Provider.ExtraValues). @@ -245,8 +298,12 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#10A37F", - ParserID: "openai", - PricingSurfaces: []string{"openai"}, + Discovery: &Discovery{ + Path: "/v1/models", + Shape: ShapeOpenAIData, + }, + ParserID: "openai", + PricingSurfaces: []string{"openai"}, // Pricing + context windows cross-checked against LiteLLM's // model_prices_and_context_window.json. Notable corrections from // earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40 @@ -284,8 +341,18 @@ var providers = []Provider{ AuthHeaderTemplate: "${API_KEY}", DefaultContentType: "application/json", BrandColor: "#D97757", - ParserID: "anthropic", - PricingSurfaces: []string{"anthropic"}, + Discovery: &Discovery{ + Path: "/v1/models", + // The default page is short and a picker wants the whole + // catalogue in one call. + Query: "limit=1000", + Shape: ShapeOpenAIData, + // Anthropic versions its API through a header and refuses a + // request that omits it, listing included. + Headers: map[string]string{"anthropic-version": "2023-06-01"}, + }, + ParserID: "anthropic", + PricingSurfaces: []string{"anthropic"}, // Per Anthropic's current model lineup. Pricing in USD per 1k // tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at // 200K. claude-3-7-sonnet and claude-3-5-haiku retired @@ -345,6 +412,22 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#FF9900", + // Listings come from the CONTROL PLANE, not the runtime host in + // DefaultHost above: ListInferenceProfiles is not an operation + // bedrock-runtime implements, and answers + // there. Inference has to go to the runtime host, so the two hosts + // genuinely differ and Discovery.Host carries the difference. + // + // Inference profiles rather than foundation models because the profile + // id is the invocable one: it carries the region prefix (eu., us., + // global.) that AWS requires and that cannot be derived from the + // configured region — an eu-central-1 account legitimately holds + // global.* profiles. + Discovery: &Discovery{ + Host: "bedrock." + RegionPlaceholder + ".amazonaws.com", + Path: "/inference-profiles", + Shape: ShapeBedrockInferenceProfiles, + }, // ParserID stays empty (path-style dispatch via IsBedrockPathStyle); // the request parser meters these under the "bedrock" surface. PricingSurfaces: []string{"bedrock"}, @@ -395,6 +478,15 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#4285F4", + // Only the v1beta1 publisher listing answers: the v1 form and the + // project-scoped form under BOTH versions return 404. That means the + // list is publisher-global — it cannot say which models this project + // has enabled — so it is offered as a suggestion beside the catalog + // rather than replacing it. See the discovery e2e for the probes. + Discovery: &Discovery{ + Path: "/v1beta1/publishers/anthropic/models", + Shape: ShapeVertexPublisherModels, + }, // ParserID stays empty (path-style dispatch via IsVertexPathStyle); // Anthropic-on-Vertex requests are metered under the "anthropic" // surface with the bare, unversioned model id. diff --git a/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go new file mode 100644 index 000000000..389c2ae50 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// discoveryManagerStub records what the handler asked for and returns a canned +// answer. The Manager interface is embedded rather than implemented: only the +// one method is reachable from this handler, and a call to any other should +// fail loudly rather than silently return a zero value. +type discoveryManagerStub struct { + agentnetwork.Manager + + gotReq modeldiscovery.Request + gotRecordID string + models []modeldiscovery.Model + err error +} + +func (s *discoveryManagerStub) DiscoverProviderModels( + _ context.Context, _, _ string, req modeldiscovery.Request, recordID string, +) ([]modeldiscovery.Model, error) { + s.gotReq = req + s.gotRecordID = recordID + return s.models, s.err +} + +// postDiscovery drives the handler with an authenticated request. +func postDiscovery(t *testing.T, stub *discoveryManagerStub, body string) *httptest.ResponseRecorder { + t.Helper() + h := &handler{manager: stub} + + req := httptest.NewRequest(http.MethodPost, "/agent-network/catalog/providers/models", strings.NewReader(body)) + req = req.WithContext(nbcontext.SetUserAuthInContext(req.Context(), auth.UserAuth{ + AccountId: "acc-1", + UserId: "user-1", + })) + + rec := httptest.NewRecorder() + h.discoverProviderModels(rec, req) + return rec +} + +func TestDiscoverModelsReturnsTheVendorList(t *testing.T) { + stub := &discoveryManagerStub{models: []modeldiscovery.Model{ + {ID: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", Label: "EU Claude Haiku 4.5", PricingKnown: true}, + {ID: "global.cohere.embed-v4:0", Label: "Global Cohere Embed v4"}, + // A vendor that supplies no display name at all. Bedrock does for + // every profile, but the OpenAI listing carries none. + {ID: "gpt-4o-mini", PricingKnown: true}, + }} + + rec := postDiscovery(t, stub, `{ + "catalog_provider_id":"bedrock_api", + "upstream_url":"https://bedrock-runtime.eu-central-1.amazonaws.com", + "api_key":"aws-bearer" + }`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var out api.AgentNetworkModelDiscoveryResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.Len(t, out.Models, 3) + + assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", out.Models[0].Id) + assert.True(t, out.Models[0].PricingKnown) + // An unpriced model must say so rather than arriving indistinguishable + // from a priced one: registering it silently would meter at zero. + assert.False(t, out.Models[1].PricingKnown) + + require.NotNil(t, out.Models[0].Label, "the vendor supplied a display name") + assert.Equal(t, "EU Claude Haiku 4.5", *out.Models[0].Label) + // A vendor that supplies no name must omit the key rather than send an + // empty string: the dashboard falls back to the id on absence, and would + // render a blank row for "". + assert.Nil(t, out.Models[2].Label, "an absent label must not serialize") + assert.NotContains(t, rec.Body.String(), `"label":""`) + + assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID) + assert.Equal(t, "aws-bearer", stub.gotReq.APIKey) + // The upstream is what the region is read back out of for Bedrock, so + // losing it here would break discovery for every regional provider. + assert.Equal(t, "https://bedrock-runtime.eu-central-1.amazonaws.com", stub.gotReq.UpstreamURL) + assert.Empty(t, stub.gotRecordID) +} + +func TestDiscoverModelsUsesAStoredRecordWithoutAKey(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"openai_api","provider_id":"prov-42"}`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + // The dashboard refreshes a saved provider's list without ever holding + // the credential, so the record id has to reach the manager. + assert.Equal(t, "prov-42", stub.gotRecordID) + assert.Empty(t, stub.gotReq.APIKey) +} + +// TestDiscoverModelsRefusesMixedCredentials covers the case where a caller +// names a saved provider AND supplies a key. Accepting it would run an +// arbitrary credential under the identity of a record the caller may only be +// permitted to read. +func TestDiscoverModelsRefusesMixedCredentials(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{ + "catalog_provider_id":"openai_api", + "provider_id":"prov-42", + "api_key":"sk-attacker" + }`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Empty(t, stub.gotRecordID, "the request must be refused before it reaches the manager") +} + +// TestDiscoverModelsReportsNoDiscoveryDistinctly matters because the caller +// falls back to the catalog's own model list on this outcome. Collapsing it +// into a generic 500 would turn "this provider has no listing endpoint" into +// "something went wrong", and the form would show an error instead of a list. +func TestDiscoverModelsReportsNoDiscoveryDistinctly(t *testing.T) { + stub := &discoveryManagerStub{err: modeldiscovery.ErrNoDiscovery} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"litellm_proxy","upstream_url":"https://gw.example.com","api_key":"sk"}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +// TestDiscoverModelsTrimsTheCatalogID pins that the id the emptiness check +// accepts is the id the manager receives. A padded value that clears the check +// but reaches the catalog untrimmed misses the lookup, and the operator is told +// their provider does not exist. +func TestDiscoverModelsTrimsTheCatalogID(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":" openai_api ","api_key":"sk"}`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "openai_api", stub.gotReq.CatalogID) +} + +// TestDiscoverModelsReportsCallerInputAsBadRequest covers the other half of the +// error mapping. These failures are all reachable from a well-formed request +// with a bad field value, so answering 500 both misinforms the operator and +// puts their typo into the server's error rate. +func TestDiscoverModelsReportsCallerInputAsBadRequest(t *testing.T) { + stub := &discoveryManagerStub{ + err: fmt.Errorf("%w: unknown catalog provider %q", modeldiscovery.ErrInvalidRequest, "nope"), + } + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"nope","api_key":"sk"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "unknown catalog provider") +} + +func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) { + for name, body := range map[string]string{ + "not json": `{`, + "no catalog provider": `{"api_key":"sk"}`, + "blank catalog provider": `{"catalog_provider_id":" ","api_key":"sk"}`, + } { + t.Run(name, func(t *testing.T) { + stub := &discoveryManagerStub{} + rec := postDiscovery(t, stub, body) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go index 0d8a44ca3..645d1da61 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -7,6 +7,7 @@ package handlers import ( "encoding/json" + "errors" "math" "net/http" "net/url" @@ -16,6 +17,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" nbcontext "github.com/netbirdio/netbird/management/server/context" @@ -32,6 +34,7 @@ type handler struct { func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) { h := &handler{manager: manager} router.HandleFunc("/agent-network/catalog/providers", h.getCatalogProviders).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/catalog/providers/models", h.discoverProviderModels).Methods("POST", "OPTIONS") router.HandleFunc("/agent-network/providers", h.getAllProviders).Methods("GET", "OPTIONS") router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST", "OPTIONS") router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET", "OPTIONS") @@ -61,6 +64,98 @@ func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, out) } +// discoverProviderModels asks the vendor which models the operator's own +// credential can reach, so the provider form can offer a live list rather than +// only the static catalog. +func (h *handler) discoverProviderModels(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var body api.AgentNetworkModelDiscoveryRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + util.WriteErrorResponse("invalid json", http.StatusBadRequest, w) + return + } + // Trimmed once and carried, not trimmed for the emptiness test and then + // discarded: a padded " openai_api " would clear the check here and miss + // the catalog lookup, reporting the provider as unknown. + catalogID := strings.TrimSpace(body.CatalogProviderId) + if catalogID == "" { + util.WriteErrorResponse("catalog_provider_id is required", http.StatusBadRequest, w) + return + } + + recordID := strValue(body.ProviderId) + req := modeldiscovery.Request{ + CatalogID: catalogID, + UpstreamURL: strValue(body.UpstreamUrl), + APIKey: strValue(body.ApiKey), + } + // One source of credential or the other, never a mix: taking a key from + // the request while addressing a saved record would let a caller run an + // arbitrary credential against a provider they can only read. + if recordID != "" && req.APIKey != "" { + util.WriteErrorResponse("provide either provider_id or api_key, not both", http.StatusBadRequest, w) + return + } + + models, err := h.manager.DiscoverProviderModels(r.Context(), userAuth.AccountId, userAuth.UserId, req, recordID) + if err != nil { + // A provider with no listing endpoint is a fact about the catalog + // entry, not a failure: the caller falls back to the catalog's own + // models, so it must be able to tell the two apart. + if errors.Is(err, modeldiscovery.ErrNoDiscovery) { + util.WriteErrorResponse(err.Error(), http.StatusUnprocessableEntity, w) + return + } + // An unknown provider, an unusable upstream, a missing region or a + // missing key are all things the caller sent, reachable from a + // well-formed request. Reporting them as 500 tells the operator the + // server broke and buries genuine faults in the error rate. + if errors.Is(err, modeldiscovery.ErrInvalidRequest) { + util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w) + return + } + util.WriteError(r.Context(), err, w) + return + } + + out := api.AgentNetworkModelDiscoveryResponse{Models: make([]api.AgentNetworkDiscoveredModel, 0, len(models))} + for _, m := range models { + entry := api.AgentNetworkDiscoveredModel{ + Id: m.ID, + PricingKnown: m.PricingKnown, + // Sent even when zero: the form prefills every discovered model as + // an editable row, and an unpriced one is shown at zero and flagged + // rather than left out. + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + // Cache rates stay absent when unset, matching the catalog + // response — a zero would read as "free", not "not applicable". + CachedInputPer1k: positiveRatePtr(m.CachedInputPer1k), + CacheReadPer1k: positiveRatePtr(m.CacheReadPer1k), + CacheCreationPer1k: positiveRatePtr(m.CacheCreationPer1k), + } + if m.Label != "" { + label := m.Label + entry.Label = &label + } + out.Models = append(out.Models, entry) + } + util.WriteJSONObject(r.Context(), w, out) +} + +// strValue reads an optional string field, treating absent as empty. +func strValue(v *string) string { + if v == nil { + return "" + } + return strings.TrimSpace(*v) +} + // applyDefaultPricing overwrites the catalog response's model rates with // the LIVE default pricing table, which may differ from the compiled-in // catalog rates when the operator provides a defaults_llm_pricing.yaml. diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 379672989..41789195e 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -13,6 +13,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" @@ -50,6 +51,7 @@ type Manager interface { CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error + DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) @@ -123,6 +125,15 @@ type managerImpl struct { permissionsManager permissions.Manager proxyController proxy.Controller + // modelDiscovery queries vendors for the models a credential can reach. + // A field rather than a package call so tests can drive it without + // reaching the network. + // + // One instance serves every request for the process's lifetime, so its + // fields must stay read-only after construction: lazy initialisation + // inside Fetch or httpClient would race across request goroutines. + modelDiscovery *modeldiscovery.Client + // reconcileCache holds the last set of synthesised proxy mappings // per account, each paired with the proxy that served it, so a change // of serving proxy can be diffed without re-deriving it. @@ -151,6 +162,7 @@ func NewManager( accountManager: accountManager, permissionsManager: permissionsManager, proxyController: proxyController, + modelDiscovery: &modeldiscovery.Client{}, reconcileCache: make(map[string]map[string]syntheticMapping), labelRng: rand.New(rand.NewSource(time.Now().UnixNano())), } @@ -170,6 +182,38 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) } +// DiscoverProviderModels asks the vendor which models a credential can reach. +// +// recordID, when set, names an existing provider whose stored credential and +// upstream are used instead of the ones in req — so the dashboard can refresh +// the list without ever holding the key. +// +// Gated on Create rather than Read: this spends the operator's credential +// against a third party, which is not something a read-only role should be +// able to make the server do. That one check also covers reading the stored +// record — Create is strictly stronger than Read here, and the lookup is +// scoped to accountID, so another account's record is never reachable. +func (m *managerImpl) DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil { + return nil, err + } + + if recordID != "" { + record, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, recordID) + if err != nil { + return nil, err + } + // The catalog id comes from the stored record too: letting the caller + // name a different one would run a provider's credential against + // whichever vendor endpoint they picked. + req.CatalogID = record.ProviderID + req.UpstreamURL = record.UpstreamURL + req.APIKey = record.APIKey + } + + return m.modelDiscovery.Fetch(ctx, req) +} + // CreateProvider persists a new provider for the account. Providers have no // settings side effects: the account's endpoint is bootstrapped separately and // explicitly via CreateSettings, and every provider in the account routes @@ -1017,6 +1061,10 @@ func (*mockManager) GetAllProviders(_ context.Context, _, _ string) ([]*types.Pr return []*types.Provider{}, nil } +func (*mockManager) DiscoverProviderModels(_ context.Context, _, _ string, _ modeldiscovery.Request, _ string) ([]modeldiscovery.Model, error) { + return nil, nil +} + func (*mockManager) GetProvider(_ context.Context, _, _, _ string) (*types.Provider, error) { return &types.Provider{}, nil } diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go new file mode 100644 index 000000000..37401820c --- /dev/null +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go @@ -0,0 +1,469 @@ +// Package modeldiscovery asks a vendor which models an operator's own +// credential can reach, so the provider form can offer a live list instead of +// only the catalog's hand-curated one. +// +// The catalog cannot know two things that matter. It goes stale — its entries +// carry comments tracking which models a vendor retired on which date — and it +// cannot see an account: which OpenAI models an org is entitled to, which +// Bedrock inference profiles a given account and region hold, which Vertex +// models a project has enabled. Those are exactly the facts an operator needs +// when filling in a provider record, and only the vendor has them. +// +// The vendor is authoritative for the model ID. The catalog remains +// authoritative for pricing, and a discovered model the catalog cannot price +// is reported as such rather than silently registered at a rate of zero. +package modeldiscovery + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "syscall" + "time" + + "golang.org/x/oauth2/google" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" +) + +const ( + // fetchTimeout bounds one vendor call end to end. A listing is a single + // small GET; anything slower is a vendor problem and the operator is + // waiting on a form. + fetchTimeout = 8 * time.Second + // maxListingBytes bounds the response we will buffer. The largest real + // listing observed is Bedrock's foundation-model catalogue at ~70KB, so + // this is a wide margin over anything legitimate. + maxListingBytes = 2 << 20 + // gcpScope matches the scope llm_router mints Vertex tokens under, so a + // credential that works for discovery works for inference too. + gcpScope = "https://www.googleapis.com/auth/cloud-platform" + // vertexKeyfilePrefix marks an api_key that is a base64 service-account + // JSON key rather than a bearer token. + vertexKeyfilePrefix = "keyfile::" +) + +// ErrNoDiscovery is returned for a catalog entry that declares no listing +// endpoint. Gateways vary too much to have one, and the caller should fall +// back to the catalog list plus free-text entry rather than treating this as +// a failure. +var ErrNoDiscovery = errors.New("provider has no model-discovery endpoint") + +// ErrInvalidRequest marks a discovery failure caused by the caller's own input +// rather than by the vendor or by this server. Every one of these is reachable +// from a well-formed request carrying a bad field value, so the handler owes +// the caller a 400 — a 500 would both misinform them and bury real server +// faults in the error rate. +var ErrInvalidRequest = errors.New("invalid discovery request") + +// Model is one discovered model. +type Model struct { + // ID is the identifier to register on the provider record, in the form the + // vendor issues it. For Bedrock that is the region-prefixed inference + // profile id, which is the only form AWS accepts at invoke time. + ID string + // Label is the vendor's display name where it supplies one. + Label string + // PricingKnown reports whether the shipped pricing table can price this + // model. False means the operator must set rates, or the request would + // meter at zero. + PricingKnown bool + // The rates below are the defaults for this model, taken from the same + // table the proxy bills with, so the form prefills exactly what a request + // would cost. All zero when PricingKnown is false — an unpriced model is + // offered at zero and flagged, rather than withheld: the vendor says the + // credential can reach it, and refusing to show it would hide a model the + // operator genuinely has. + InputPer1k float64 + OutputPer1k float64 + CachedInputPer1k float64 + CacheReadPer1k float64 + CacheCreationPer1k float64 +} + +// Request identifies which vendor to ask and with what credential. +type Request struct { + // CatalogID selects the catalog entry, which supplies the endpoint, the + // auth header and the response shape. The caller never supplies those. + CatalogID string + // UpstreamURL is the provider record's configured upstream. It is used + // only when the catalog entry declares no discovery host of its own. + UpstreamURL string + // Region substitutes the catalog host's placeholder. + Region string + // APIKey is the operator's credential, exactly as stored on the record. + APIKey string +} + +// Client fetches model listings. The zero value is usable; Resolver and +// HTTPClient exist so tests can drive it against a local server. +type Client struct { + HTTPClient *http.Client + // Resolver looks up the host for the SSRF check. Nil uses the default. + Resolver *net.Resolver + // AllowPrivateHosts disables the private-address guard. Only tests set it: + // their server is on loopback, which is precisely what the guard blocks. + AllowPrivateHosts bool +} + +// Fetch returns the models the credential can reach. +func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) { + entry, ok := catalog.Lookup(req.CatalogID) + if !ok { + return nil, fmt.Errorf("%w: unknown catalog provider %q", ErrInvalidRequest, req.CatalogID) + } + if entry.Discovery == nil { + return nil, ErrNoDiscovery + } + + endpoint, err := c.discoveryURL(entry, req) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("build discovery request: %w", err) + } + if err := applyAuth(httpReq, entry, req.APIKey); err != nil { + return nil, err + } + for name, value := range entry.Discovery.Headers { + httpReq.Header.Set(name, value) + } + httpReq.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("reach %s: %w", entry.Name, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxListingBytes)) + if err != nil { + return nil, fmt.Errorf("read %s listing: %w", entry.Name, err) + } + if resp.StatusCode != http.StatusOK { + // Surface the vendor's own status. An operator whose key lacks a scope + // needs to see 403 rather than a generic failure. + return nil, fmt.Errorf("%s returned %d for its model listing", entry.Name, resp.StatusCode) + } + + ids, err := parseListing(entry.Discovery.Shape, body) + if err != nil { + return nil, err + } + return decorate(entry, ids), nil +} + +// discoveryURL builds the listing URL and refuses one that does not point at a +// public host. +// +// The path, query and (for Bedrock) the host all come from the catalog rather +// than from the caller, so the only operator-controlled part is the host of an +// entry whose listing lives on its own upstream. That still has to be checked: +// management holds credentials for every provider, and an upstream pointed at +// an internal address would turn this endpoint into a probe of the management +// server's own network. +func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, error) { + host := entry.Discovery.Host + if host == "" { + parsed, err := url.Parse(strings.TrimSpace(req.UpstreamURL)) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("%w: provider upstream %q is not a usable URL", ErrInvalidRequest, req.UpstreamURL) + } + host = parsed.Host + } + if strings.Contains(host, catalog.RegionPlaceholder) { + region := strings.TrimSpace(req.Region) + if region == "" { + // A provider record carries no region field: the region lives + // inside the upstream host the operator already configured, so + // read it back out rather than asking them for it twice. + region = regionFromUpstream(entry, req.UpstreamURL) + } + if region == "" { + return "", fmt.Errorf("%w: %s discovery needs a region, and none could be read from the provider upstream", + ErrInvalidRequest, entry.Name) + } + host = strings.ReplaceAll(host, catalog.RegionPlaceholder, region) + } + + target := &url.URL{Scheme: "https", Host: host, Path: entry.Discovery.Path, RawQuery: entry.Discovery.Query} + if err := c.checkPublicHost(target.Hostname()); err != nil { + return "", err + } + return target.String(), nil +} + +// regionFromUpstream recovers the region an operator embedded in the provider +// upstream, by matching it against the catalog's own host template. Bedrock's +// template is "bedrock-runtime..amazonaws.com" and Vertex's is +// "-aiplatform.googleapis.com", so the region is whatever sits between +// the fixed halves. Returns empty when the upstream does not match the +// template, which is the case for a custom or proxied endpoint. +func regionFromUpstream(entry catalog.Provider, upstreamURL string) string { + prefix, suffix, found := strings.Cut(entry.DefaultHost, catalog.RegionPlaceholder) + if !found { + return "" + } + parsed, err := url.Parse(strings.TrimSpace(upstreamURL)) + if err != nil { + return "" + } + host := parsed.Hostname() + if host == "" { + // A bare host with no scheme parses as a path, not a host. + host = strings.TrimSpace(upstreamURL) + } + // The two halves must not overlap. "bedrock-runtime.amazonaws.com" carries + // both of Bedrock's — it is the regionless endpoint — and satisfies both + // checks above while leaving nothing between them, so slicing it would + // panic on an inverted range rather than report "no region here". + if !strings.HasPrefix(host, prefix) || !strings.HasSuffix(host, suffix) || + len(host) < len(prefix)+len(suffix) { + return "" + } + region := host[len(prefix) : len(host)-len(suffix)] + if region == "" || strings.Contains(region, ".") { + return "" + } + return region +} + +// checkPublicHost refuses hosts that resolve to an address the management +// server should never be asked to reach on an operator's behalf. +func (c *Client) checkPublicHost(host string) error { + if c.AllowPrivateHosts { + return nil + } + if host == "" { + return errors.New("discovery host is empty") + } + resolver := c.Resolver + if resolver == nil { + resolver = net.DefaultResolver + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + addrs, err := resolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("resolve discovery host %q: %w", host, err) + } + // Every address must be public: a name that resolves to one public and one + // loopback address is still a way to reach loopback. + for _, addr := range addrs { + if !isPublic(addr) { + return fmt.Errorf("%w: discovery host %q resolves to a non-public address", ErrInvalidRequest, host) + } + } + return nil +} + +// isPublic reports whether an address is one we are willing to dial. +func isPublic(addr netip.Addr) bool { + addr = addr.Unmap() + switch { + case !addr.IsValid(), + addr.IsLoopback(), + addr.IsPrivate(), + addr.IsLinkLocalUnicast(), + addr.IsLinkLocalMulticast(), + addr.IsInterfaceLocalMulticast(), + addr.IsMulticast(), + addr.IsUnspecified(): + return false + } + // 100.64.0.0/10 (carrier NAT) is where NetBird's own overlay addresses + // live, so it is emphatically not somewhere to send a provider credential. + if addr.Is4() { + b := addr.As4() + if b[0] == 100 && b[1] >= 64 && b[1] <= 127 { + return false + } + } + return true +} + +// applyAuth sets the credential header the catalog entry declares. A Vertex +// service-account key is exchanged for an OAuth token first, the same way the +// proxy does at request time. +func applyAuth(req *http.Request, entry catalog.Provider, apiKey string) error { + key := strings.TrimSpace(apiKey) + if key == "" { + return fmt.Errorf("%w: %s discovery needs an API key", ErrInvalidRequest, entry.Name) + } + if rest, ok := strings.CutPrefix(key, vertexKeyfilePrefix); ok { + token, err := mintGCPToken(req.Context(), rest) + if err != nil { + return err + } + key = token + } + name := entry.AuthHeaderName + if name == "" { + name = "Authorization" + } + template := entry.AuthHeaderTemplate + if template == "" { + template = "${API_KEY}" + } + req.Header.Set(name, strings.ReplaceAll(template, "${API_KEY}", key)) + return nil +} + +// mintGCPToken exchanges a base64 service-account key for an access token. +func mintGCPToken(ctx context.Context, saKeyB64 string) (string, error) { + jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64)) + if err != nil { + return "", fmt.Errorf("decode service-account key: %w", err) + } + conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope) + if err != nil { + return "", fmt.Errorf("parse service-account key: %w", err) + } + tok, err := conf.TokenSource(ctx).Token() + if err != nil { + return "", fmt.Errorf("mint gcp token: %w", err) + } + return tok.AccessToken, nil +} + +// decorate turns raw vendor ids into the models the caller renders, attaching +// the rates the request would actually be billed at. +// +// Rates come from the live default pricing table rather than the compiled-in +// catalog, because that is the table the synthesiser ships to the proxy: an +// operator running a defaults_llm_pricing.yaml would otherwise be shown one +// price in the form and charged another. It is also the same lookup the catalog +// endpoint prefills from, so a model reached by either route prices identically. +func decorate(entry catalog.Provider, ids []listedModel) []Model { + out := make([]Model, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, listed := range ids { + if listed.id == "" { + continue + } + if _, dup := seen[listed.id]; dup { + continue + } + seen[listed.id] = struct{}{} + + // The table keys pricing by the normalised id while the vendor issues + // the wire form, so normalise before looking it up — otherwise every + // Bedrock profile would report unpriced. + model := Model{ID: listed.id, Label: listed.label} + if rate, known := pricing.LookupDefault(entry.PricingSurfaces, normalizeForPricing(entry.ID, listed.id)); known { + model.PricingKnown = true + model.InputPer1k = rate.InputPer1k + model.OutputPer1k = rate.OutputPer1k + model.CachedInputPer1k = rate.CachedInputPer1k + model.CacheReadPer1k = rate.CacheReadPer1k + model.CacheCreationPer1k = rate.CacheCreationPer1k + } + out = append(out, model) + } + return out +} + +// refuseRedirect is the redirect policy every discovery request runs under. A +// redirect is a way to move the request to a host checkPublicHost never saw, +// so none are followed. +func refuseRedirect(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + if c.HTTPClient.CheckRedirect != nil { + return c.HTTPClient + } + // An injected client that states no policy still gets ours: the + // no-redirect guarantee should not depend on the caller remembering it. + // + // Copied rather than assigned into: one Client is shared by every + // request for the process's lifetime, so writing to its fields here + // would race across request goroutines. The copy shares the Transport, + // which is safe for concurrent use by design. + clone := *c.HTTPClient + clone.CheckRedirect = refuseRedirect + return &clone + } + transport := guardedTransport + if c.AllowPrivateHosts { + transport = http.DefaultTransport + } + return &http.Client{ + Timeout: fetchTimeout, + Transport: transport, + CheckRedirect: refuseRedirect, + } +} + +// guardedTransport dials only addresses isPublic accepts. +// +// checkPublicHost resolves the host itself, and the transport then resolves it +// again when it dials — two lookups of a name whose owner chooses the answers. +// A record that returns a public address to the first and 127.0.0.1 to the +// second passes the guard and reaches loopback anyway, which is the whole of +// DNS rebinding. Re-checking at the socket closes that window: whatever the +// second lookup returned is what Control is handed, and an address the guard +// refuses never gets connected. +// +// Shared package-wide rather than built per Fetch so connections and their +// pool survive between calls; the guard holds no state. +var guardedTransport = newGuardedTransport() + +func newGuardedTransport() http.RoundTripper { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + // Something replaced the default transport. Fall back to it rather + // than dropping its behaviour, and rely on checkPublicHost alone. + return http.DefaultTransport + } + // Cloned so proxy settings, TLS defaults and timeouts come from the + // standard transport rather than being restated here. + transport := base.Clone() + dialer := &net.Dialer{ + Timeout: fetchTimeout, + KeepAlive: 30 * time.Second, + Control: func(_, address string, _ syscall.RawConn) error { + return guardDialAddress(address) + }, + } + transport.DialContext = dialer.DialContext + return transport +} + +// guardDialAddress refuses a resolved socket address the discovery client has +// no business connecting to. Control hands it over post-resolution and +// pre-connect, once per address the dialer tries, so a name with several A +// records is checked at each one. +func guardDialAddress(address string) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("discovery dial address %q is unreadable", address) + } + addr, err := netip.ParseAddr(host) + if err != nil { + // Control is documented to receive a resolved address; anything else + // is a state we cannot vet, so it does not get dialled. + return fmt.Errorf("discovery dial address %q is not an IP", host) + } + if !isPublic(addr) { + return fmt.Errorf("discovery refused to dial non-public address %s", addr) + } + return nil +} diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go new file mode 100644 index 000000000..fba2c97d1 --- /dev/null +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go @@ -0,0 +1,496 @@ +package modeldiscovery + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" +) + +// stubTransport answers every request with one canned response and records the +// request it was given, so a test can assert on the URL and headers the client +// built without a network round trip. +type stubTransport struct { + status int + body string + got *http.Request +} + +func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) { + s.got = req + status := s.status + if status == 0 { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(s.body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil +} + +// newStubClient returns a client that never leaves the process. The host guard +// is disabled because it would otherwise resolve the vendor's real name, which +// would make these tests depend on DNS. +func newStubClient(status int, body string) (*Client, *stubTransport) { + tr := &stubTransport{status: status, body: body} + return &Client{ + HTTPClient: &http.Client{Transport: tr}, + AllowPrivateHosts: true, + }, tr +} + +// The payloads below are trimmed from what the vendors actually returned in +// the discovery e2e, rather than invented, so a parser that only works against +// an idealised shape fails here. + +const openAIListing = `{"object":"list","data":[ + {"id":"gpt-4o-mini","object":"model","created":1721172741,"owned_by":"system"}, + {"id":"gpt-4o","object":"model","created":1715367049,"owned_by":"system"} +]}` + +const anthropicListing = `{"data":[ + {"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5"}, + {"type":"model","id":"claude-sonnet-4-6","display_name":"Claude Sonnet 4.6"} +],"has_more":false}` + +const bedrockListing = `{"inferenceProfileSummaries":[ + {"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "inferenceProfileName":"EU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"global.cohere.embed-v4:0", + "inferenceProfileName":"Global Cohere Embed v4","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"eu.meta.llama3-2-1b-instruct-v1:0", + "inferenceProfileName":"EU Meta Llama 3.2 1B","status":"INACTIVE","type":"SYSTEM_DEFINED"} +]}` + +const vertexListing = `{"publisherModels":[ + {"name":"publishers/anthropic/models/claude-3-opus","versionId":"20240229","launchStage":"GA"}, + {"name":"publishers/anthropic/models/claude-sonnet-4-5","versionId":"20250929","launchStage":"GA"} +]}` + +func TestFetchOpenAIListing(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, openAIListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "openai_api", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + }) + require.NoError(t, err) + + assert.Equal(t, "https://api.openai.com/v1/models", tr.got.URL.String()) + assert.Equal(t, "Bearer sk-test", tr.got.Header.Get("Authorization"), + "the credential must be injected through the catalog's auth template") + assert.Equal(t, []string{"gpt-4o-mini", "gpt-4o"}, ids(models)) + for _, m := range models { + assert.True(t, m.PricingKnown, "both models are in the shipped catalog: %s", m.ID) + } +} + +func TestFetchAnthropicSendsTheVersionHeader(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, anthropicListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "anthropic_api", + UpstreamURL: "https://api.anthropic.com", + APIKey: "sk-ant-test", + }) + require.NoError(t, err) + + // Anthropic rejects a request without the version header, so a listing + // that reached us at all proves it was sent — but assert it, because the + // failure mode otherwise only shows up against the live API. + assert.Equal(t, "2023-06-01", tr.got.Header.Get("anthropic-version")) + assert.Equal(t, "sk-ant-test", tr.got.Header.Get("x-api-key"), + "Anthropic takes a bare key under its own header, not a Bearer token") + assert.Equal(t, "limit=1000", tr.got.URL.RawQuery) + + assert.Equal(t, []string{"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}, ids(models)) + assert.Equal(t, "Claude Haiku 4.5", models[0].Label) +} + +func TestFetchBedrockUsesTheControlPlaneAndKeepsWireIDs(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, bedrockListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + // The record's upstream is the RUNTIME host, which does not serve + // listings. The catalog's own discovery host must win over it. + UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com", + Region: "eu-central-1", + APIKey: "aws-bearer", + }) + require.NoError(t, err) + + assert.Equal(t, "https://bedrock.eu-central-1.amazonaws.com/inference-profiles", + tr.got.URL.String(), "listings come from the control plane, not the runtime host") + + // Region-prefixed ids verbatim: the prefix is what makes them invocable + // and it cannot be reconstructed — global.* alongside eu.* is exactly the + // case that defeats deriving it from the configured region. + assert.Equal(t, []string{ + "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "global.cohere.embed-v4:0", + }, ids(models), "an INACTIVE profile must not be offered") + + assert.True(t, models[0].PricingKnown, + "the catalog prices anthropic.claude-haiku-4-5, which this id normalises to") + assert.False(t, models[1].PricingKnown, + "cohere embed is not in the shipped Bedrock catalog, so the operator must price it") + + // The rates travel with the model, so the form can prefill an editable row + // rather than making the operator look every price up by hand. + assert.Positive(t, models[0].InputPer1k, "a priced model must carry its input rate") + assert.Positive(t, models[0].OutputPer1k, "a priced model must carry its output rate") + // An unpriced model is offered at zero and flagged, not withheld: the + // vendor says the credential can reach it. + assert.Zero(t, models[1].InputPer1k) + assert.Zero(t, models[1].OutputPer1k) +} + +// TestDiscoveredRatesMatchTheCatalogEndpoint pins the two prefill paths to one +// table. The provider form fills a model row either from the catalog response +// or from a discovery response, and an operator who switches between them must +// not see the price change — both must equal what the proxy will bill. +func TestDiscoveredRatesMatchTheCatalogEndpoint(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "openai_api", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + }) + require.NoError(t, err) + require.NotEmpty(t, models) + + entry, ok := catalog.Lookup("openai_api") + require.True(t, ok) + + for _, m := range models { + want, known := pricing.LookupDefault(entry.PricingSurfaces, m.ID) + require.True(t, known, "%s should be priced by the default table", m.ID) + assert.Equal(t, want.InputPer1k, m.InputPer1k, "input rate for %s", m.ID) + assert.Equal(t, want.OutputPer1k, m.OutputPer1k, "output rate for %s", m.ID) + assert.Equal(t, want.CachedInputPer1k, m.CachedInputPer1k, "cached-input rate for %s", m.ID) + } +} + +func TestFetchVertexJoinsNameAndVersion(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, vertexListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "vertex_ai_api", + UpstreamURL: "https://us-east5-aiplatform.googleapis.com", + Region: "us-east5", + APIKey: "ya29.test-token", + }) + require.NoError(t, err) + + // Vertex addresses a model as "@" on rawPredict, and splits + // those across two fields in the listing. + assert.Equal(t, []string{"claude-3-opus@20240229", "claude-sonnet-4-5@20250929"}, ids(models)) + assert.Equal(t, "claude-3-opus", models[0].Label) +} + +func TestFetchSurfacesTheVendorStatus(t *testing.T) { + cl, _ := newStubClient(http.StatusForbidden, `{"error":{"message":"no access"}}`) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "openai_api", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "403", + "an operator whose key lacks access needs to see which status the vendor returned") +} + +func TestFetchRejectsAProviderWithoutDiscovery(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "litellm_proxy", + UpstreamURL: "https://gateway.example.com", + APIKey: "sk-test", + }) + assert.ErrorIs(t, err, ErrNoDiscovery, + "a gateway with no listing endpoint must be distinguishable from a failure, so the caller can fall back") +} + +func TestFetchRequiresACredential(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "openai_api", + UpstreamURL: "https://api.openai.com", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "API key") +} + +func TestDiscoveryURLNeedsARegionWhenTheHostTemplatesOne(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, bedrockListing) + + // An upstream that matches no catalog template — a proxy in front of + // Bedrock, say — leaves nothing to read the region from. Refusing beats + // guessing: an unsubstituted placeholder would dial a host that does not + // exist, and a guessed region would dial the wrong account's endpoint. + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock.internal-proxy.example.com", + APIKey: "aws-bearer", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "region") +} + +// TestHostGuardRejectsNonPublicAddresses is the SSRF guard. Management holds a +// credential for every provider, so an upstream pointed at an internal address +// would turn discovery into a way to probe — and hand a token to — the +// management server's own network. +func TestHostGuardRejectsNonPublicAddresses(t *testing.T) { + for _, tc := range []struct { + name string + addr string + want bool + }{ + {"loopback v4", "127.0.0.1", false}, + {"loopback v6", "::1", false}, + {"private 10/8", "10.0.0.5", false}, + {"private 172.16/12", "172.16.4.1", false}, + {"private 192.168/16", "192.168.1.1", false}, + {"link-local", "169.254.169.254", false}, // cloud metadata + {"unspecified", "0.0.0.0", false}, + {"multicast", "224.0.0.1", false}, + {"netbird overlay 100.64/10", "100.90.1.2", false}, + {"v4-mapped loopback", "::ffff:127.0.0.1", false}, + {"public v4", "1.1.1.1", true}, + {"public v6", "2606:4700:4700::1111", true}, + {"just outside CGNAT", "100.128.0.1", true}, + } { + t.Run(tc.name, func(t *testing.T) { + addr, err := netip.ParseAddr(tc.addr) + require.NoError(t, err) + assert.Equal(t, tc.want, isPublic(addr)) + }) + } +} + +func TestHostGuardResolvesAndRejectsLocalhost(t *testing.T) { + cl := &Client{} + err := cl.checkPublicHost("localhost") + require.Error(t, err, "a name resolving to loopback must be refused, not just a literal address") + assert.Contains(t, err.Error(), "non-public") +} + +// TestRedirectsAreNotFollowed covers a gap the other tests leave open: they all +// inject an HTTPClient, which bypasses httpClient() and therefore the redirect +// policy entirely. The policy is a security control — a 302 moves the request +// to a host checkPublicHost never resolved — so it needs a test that goes +// through the constructor the manager actually uses. +func TestRedirectsAreNotFollowed(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) + })) + t.Cleanup(srv.Close) + + for name, cl := range map[string]*Client{ + // The production shape: no injected client at all. + "default client": {AllowPrivateHosts: true}, + // An injected client that states no policy must inherit ours rather + // than silently chasing the redirect. + "injected client with no policy": { + AllowPrivateHosts: true, + HTTPClient: &http.Client{}, + }, + } { + t.Run(name, func(t *testing.T) { + hits = 0 + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) + require.NoError(t, err) + + resp, err := cl.httpClient().Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, http.StatusFound, resp.StatusCode, + "the redirect must be surfaced, not followed to an unchecked host") + assert.Equal(t, 1, hits, "exactly one request must leave the client") + }) + } +} + +// TestInjectedClientKeepsItsOwnRedirectPolicy pins that the default above is a +// default, not an override, and that supplying it does not mutate the caller's +// client — one Client is shared across every request, so a write here would +// race. +func TestInjectedClientKeepsItsOwnRedirectPolicy(t *testing.T) { + own := func(*http.Request, []*http.Request) error { return nil } + injected := &http.Client{CheckRedirect: own} + cl := &Client{HTTPClient: injected} + + assert.Same(t, injected, cl.httpClient(), + "a client that states a policy must be handed back untouched") + + bare := &http.Client{} + cl = &Client{HTTPClient: bare} + require.NotSame(t, bare, cl.httpClient(), "the policy must be applied to a copy") + assert.Nil(t, bare.CheckRedirect, "the caller's client must not be written to") +} + +// TestDialGuardRejectsRebindingToANonPublicAddress covers the window between +// the two DNS lookups. checkPublicHost resolves the host, then the transport +// resolves it again to dial; a name whose owner answers the first with a public +// address and the second with 127.0.0.1 would otherwise pass the guard and +// still reach loopback. The dial-time check sees whatever the second lookup +// actually returned. +func TestDialGuardRejectsRebindingToANonPublicAddress(t *testing.T) { + for _, tc := range []struct { + name string + address string + wantErr string + }{ + {"loopback", "127.0.0.1:443", "non-public"}, + {"cloud metadata", "169.254.169.254:80", "non-public"}, + {"rfc1918", "10.1.2.3:443", "non-public"}, + {"netbird overlay", "100.90.1.2:443", "non-public"}, + {"loopback v6", "[::1]:443", "non-public"}, + {"unresolved name", "evil.example.com:443", "not an IP"}, + {"no port", "1.1.1.1", "unreadable"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := guardDialAddress(tc.address) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } + + assert.NoError(t, guardDialAddress("1.1.1.1:443"), "a public address must still be dialled") + assert.NoError(t, guardDialAddress("[2606:4700:4700::1111]:443")) +} + +// TestDialGuardIsInstalledOnTheDefaultClient pins the wiring rather than the +// guard: a correct guard nothing calls protects nothing. +func TestDialGuardIsInstalledOnTheDefaultClient(t *testing.T) { + cl := &Client{} + transport, ok := cl.httpClient().Transport.(*http.Transport) + require.True(t, ok, "the default discovery client must carry the guarded transport") + require.NotNil(t, transport.DialContext, "the guarded transport must dial through the guard") + + _, err := transport.DialContext(context.Background(), "tcp", "127.0.0.1:9") + require.Error(t, err, "the guard must refuse loopback even when the caller dials it directly") + assert.Contains(t, err.Error(), "non-public") + + // Tests point the client at a loopback server on purpose, so the opt-out + // has to reach the dialer too. + relaxed := &Client{AllowPrivateHosts: true} + assert.Equal(t, http.DefaultTransport, relaxed.httpClient().Transport) +} + +// TestCallerInputFailuresAreMarkedInvalid keeps the handler's 400 mapping +// honest: it branches on this sentinel, so an unmarked caller-input failure +// silently becomes a 500. +func TestCallerInputFailuresAreMarkedInvalid(t *testing.T) { + for _, tc := range []struct { + name string + req Request + }{ + {"unknown provider", Request{CatalogID: "not_a_provider", APIKey: "k"}}, + {"unusable upstream", Request{CatalogID: "openai_api", UpstreamURL: "://", APIKey: "k"}}, + {"missing api key", Request{CatalogID: "openai_api", UpstreamURL: "https://api.openai.com"}}, + {"no region to read", Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock-runtime.amazonaws.com", + APIKey: "aws-bearer", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + _, err := cl.Fetch(context.Background(), tc.req) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRequest) + }) + } +} + +// TestEveryDiscoveryEntryHasAParser keeps the catalog and the parser table from +// drifting: adding a Discovery block with a shape nothing parses would fail +// only at runtime, in front of an operator. +func TestEveryDiscoveryEntryHasAParser(t *testing.T) { + for _, entry := range catalog.All() { + if entry.Discovery == nil { + continue + } + t.Run(entry.ID, func(t *testing.T) { + assert.NotEmpty(t, entry.Discovery.Path, "a discovery entry needs a path") + _, err := parseListing(entry.Discovery.Shape, []byte(`{}`)) + assert.NoError(t, err, "shape %q has no parser", entry.Discovery.Shape) + }) + } +} + +func ids(models []Model) []string { + out := make([]string, 0, len(models)) + for _, m := range models { + out = append(out, m.ID) + } + return out +} + +// TestRegionIsReadBackFromTheUpstream covers the reason the API takes no +// region field: a provider record has none, and the operator already encoded +// it in the upstream host when they configured inference. +func TestRegionIsReadBackFromTheUpstream(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, bedrockListing) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock-runtime.us-west-2.amazonaws.com", + APIKey: "aws-bearer", + }) + require.NoError(t, err) + assert.Equal(t, "bedrock.us-west-2.amazonaws.com", tr.got.URL.Host) +} + +func TestRegionFromUpstream(t *testing.T) { + bedrock, ok := catalog.Lookup("bedrock_api") + require.True(t, ok) + vertex, ok := catalog.Lookup("vertex_ai_api") + require.True(t, ok) + + for _, tc := range []struct { + name string + entry catalog.Provider + upstream string + want string + }{ + {"bedrock runtime host", bedrock, "https://bedrock-runtime.eu-central-1.amazonaws.com", "eu-central-1"}, + {"bedrock without scheme", bedrock, "bedrock-runtime.ap-south-1.amazonaws.com", "ap-south-1"}, + {"vertex regional host", vertex, "https://us-east5-aiplatform.googleapis.com", "us-east5"}, + // A proxied or self-hosted upstream matches no template, and guessing + // a region from it would build a URL pointing somewhere arbitrary. + {"unrelated upstream", bedrock, "https://llm.internal.example.com", ""}, + {"vertex global host has no region segment", vertex, "https://aiplatform.googleapis.com", ""}, + // Bedrock's regionless endpoint carries both halves of the template at + // once, with nothing between them. It has to read as "no region here" + // rather than as an inverted slice range. + {"bedrock regionless endpoint", bedrock, "https://bedrock-runtime.amazonaws.com", ""}, + {"bedrock regionless without scheme", bedrock, "bedrock-runtime.amazonaws.com", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, regionFromUpstream(tc.entry, tc.upstream)) + }) + } +} diff --git a/management/internals/modules/agentnetwork/modeldiscovery/parse.go b/management/internals/modules/agentnetwork/modeldiscovery/parse.go new file mode 100644 index 000000000..83048cb8a --- /dev/null +++ b/management/internals/modules/agentnetwork/modeldiscovery/parse.go @@ -0,0 +1,134 @@ +package modeldiscovery + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + sharedllm "github.com/netbirdio/netbird/shared/llm" +) + +// listedModel is one entry lifted out of a vendor listing before the catalog +// is consulted about it. +type listedModel struct { + id string + label string +} + +// parseListing extracts model ids from a vendor listing. Each vendor invented +// its own envelope, and the shape is declared by the catalog rather than +// sniffed, so a vendor that changes shape fails loudly instead of silently +// returning nothing. +func parseListing(shape catalog.ListingShape, body []byte) ([]listedModel, error) { + switch shape { + case catalog.ShapeOpenAIData: + return parseOpenAIData(body) + case catalog.ShapeBedrockInferenceProfiles: + return parseBedrockInferenceProfiles(body) + case catalog.ShapeVertexPublisherModels: + return parseVertexPublisherModels(body) + default: + return nil, fmt.Errorf("no parser for listing shape %q", shape) + } +} + +// parseOpenAIData reads {"data":[{"id":…}]}, which OpenAI defined and +// Anthropic adopted. Anthropic additionally supplies display_name. +func parseOpenAIData(body []byte) ([]listedModel, error) { + var doc struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + } `json:"data"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decode model listing: %w", err) + } + out := make([]listedModel, 0, len(doc.Data)) + for _, entry := range doc.Data { + out = append(out, listedModel{id: entry.ID, label: entry.DisplayName}) + } + return out, nil +} + +// parseBedrockInferenceProfiles reads +// {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. +// +// The profile id is taken verbatim because its region prefix (eu., us., +// global.) is what makes it invocable, and it is not derivable from the +// configured region — an account in one region legitimately holds global.* +// profiles alongside its regional ones. +// +// Only ACTIVE profiles are offered: AWS reports others, and registering one +// would produce a model that routes inside NetBird and fails at AWS. +func parseBedrockInferenceProfiles(body []byte) ([]listedModel, error) { + var doc struct { + Summaries []struct { + ID string `json:"inferenceProfileId"` + Name string `json:"inferenceProfileName"` + Status string `json:"status"` + } `json:"inferenceProfileSummaries"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decode inference-profile listing: %w", err) + } + out := make([]listedModel, 0, len(doc.Summaries)) + for _, entry := range doc.Summaries { + if entry.Status != "" && !strings.EqualFold(entry.Status, "ACTIVE") { + continue + } + out = append(out, listedModel{id: entry.ID, label: entry.Name}) + } + return out, nil +} + +// parseVertexPublisherModels reads {"publisherModels":[{"name":…}]}, where +// name is a resource path ("publishers/anthropic/models/claude-3-opus") and +// the version lives in a separate field. +// +// Vertex addresses a model as "@" on the rawPredict path, so the +// two are joined here: reporting the bare name would hand the operator an id +// that looks usable and is not. +func parseVertexPublisherModels(body []byte) ([]listedModel, error) { + var doc struct { + Models []struct { + Name string `json:"name"` + VersionID string `json:"versionId"` + } `json:"publisherModels"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decode publisher-model listing: %w", err) + } + out := make([]listedModel, 0, len(doc.Models)) + for _, entry := range doc.Models { + id := entry.Name + if slash := strings.LastIndex(id, "/"); slash >= 0 { + id = id[slash+1:] + } + if id == "" { + continue + } + label := id + if entry.VersionID != "" { + id += "@" + entry.VersionID + } + out = append(out, listedModel{id: id, label: label}) + } + return out, nil +} + +// normalizeForPricing maps a vendor's wire id onto the key the catalog prices +// it under. It mirrors the synthesiser's normalizePricingModelID: the two must +// agree, or a model reported here as priced would meter at the default rate +// instead of the operator's. +func normalizeForPricing(catalogProviderID, modelID string) string { + switch { + case catalog.IsBedrockPathStyle(catalogProviderID): + return sharedllm.NormalizeBedrockModel(modelID) + case catalog.IsVertexPathStyle(catalogProviderID): + return sharedllm.NormalizeVertexModel(modelID) + default: + return modelID + } +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index bfceadeef..3ab5a2e42 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5335,6 +5335,84 @@ components: - input_per_1k - output_per_1k - context_window + AgentNetworkModelDiscoveryRequest: + type: object + properties: + catalog_provider_id: + type: string + description: Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape. + example: "bedrock_api" + upstream_url: + type: string + description: | + The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied. + example: "https://bedrock-runtime.eu-central-1.amazonaws.com" + api_key: + type: string + description: Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id. + example: "sk-..." + provider_id: + type: string + description: Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key. + example: "ch8i4ug6lnn4g9hqv7m0" + required: + - catalog_provider_id + AgentNetworkModelDiscoveryResponse: + type: object + properties: + models: + type: array + description: Models the credential can reach, in the order the vendor returned them. + items: + $ref: '#/components/schemas/AgentNetworkDiscoveredModel' + required: + - models + AgentNetworkDiscoveredModel: + type: object + properties: + id: + type: string + description: | + Identifier to register on the provider record, in the form the vendor issues it. For Bedrock this is the region-prefixed inference-profile id, which is the only form AWS accepts at invoke time. + example: "eu.anthropic.claude-haiku-4-5-20251001-v1:0" + label: + type: string + description: Vendor-supplied display name, where the vendor supplies one. + example: "EU Anthropic Claude Haiku 4.5" + pricing_known: + type: boolean + description: Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero. + example: true + input_per_1k: + type: number + format: double + description: Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false. + example: 0.005 + output_per_1k: + type: number + format: double + description: Default output token price per 1k tokens, in USD. Zero when pricing_known is false. + example: 0.015 + cached_input_per_1k: + type: number + format: double + description: OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount. + example: 0.000075 + cache_read_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate. + example: 0.0003 + cache_creation_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate. + example: 0.00375 + required: + - id + - pricing_known + - input_per_1k + - output_per_1k AgentNetworkCatalogProvider: type: object properties: @@ -14004,6 +14082,42 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/agent-network/catalog/providers/models: + post: + summary: Discover the models a provider credential can reach + description: | + Asks the vendor which models the supplied credential can actually use, so the provider form can offer a live list instead of only the static catalog. The endpoint, auth header and response shape are taken from the catalog entry, never from the request. + + Supply either an api_key together with the upstream_url being configured (before the provider is saved), or a provider_id of an existing record to reuse its stored credential. + + Returns 422 for a catalog provider that has no listing endpoint (most gateways); the caller should fall back to the catalog's own model list. A model whose price the shipped table does not know is returned with pricing_known false, and the operator must set rates for it. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkModelDiscoveryRequest' + responses: + '200': + description: The models the credential can reach + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkModelDiscoveryResponse' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '422': + "$ref": "#/components/responses/validation_failed_simple" + '500': + "$ref": "#/components/responses/internal_error" /api/agent-network/providers: get: summary: List all Agent Network Providers diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 04e04a24f..db5b2e18e 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2120,6 +2120,33 @@ type AgentNetworkConsumption struct { // AgentNetworkConsumptionDimensionKind Whether this row counts a single end user or a single source group across every member. type AgentNetworkConsumptionDimensionKind string +// AgentNetworkDiscoveredModel defines model for AgentNetworkDiscoveredModel. +type AgentNetworkDiscoveredModel struct { + // CacheCreationPer1k Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate. + CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"` + + // CacheReadPer1k Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate. + CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"` + + // CachedInputPer1k OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount. + CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"` + + // Id Identifier to register on the provider record, in the form the vendor issues it. For Bedrock this is the region-prefixed inference-profile id, which is the only form AWS accepts at invoke time. + Id string `json:"id"` + + // InputPer1k Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false. + InputPer1k float64 `json:"input_per_1k"` + + // Label Vendor-supplied display name, where the vendor supplies one. + Label *string `json:"label,omitempty"` + + // OutputPer1k Default output token price per 1k tokens, in USD. Zero when pricing_known is false. + OutputPer1k float64 `json:"output_per_1k"` + + // PricingKnown Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero. + PricingKnown bool `json:"pricing_known"` +} + // AgentNetworkGuardrail defines model for AgentNetworkGuardrail. type AgentNetworkGuardrail struct { // Checks Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert. @@ -2167,6 +2194,27 @@ type AgentNetworkGuardrailRequest struct { Name string `json:"name"` } +// AgentNetworkModelDiscoveryRequest defines model for AgentNetworkModelDiscoveryRequest. +type AgentNetworkModelDiscoveryRequest struct { + // ApiKey Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id. + ApiKey *string `json:"api_key,omitempty"` + + // CatalogProviderId Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape. + CatalogProviderId string `json:"catalog_provider_id"` + + // ProviderId Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key. + ProviderId *string `json:"provider_id,omitempty"` + + // UpstreamUrl The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied. + UpstreamUrl *string `json:"upstream_url,omitempty"` +} + +// AgentNetworkModelDiscoveryResponse defines model for AgentNetworkModelDiscoveryResponse. +type AgentNetworkModelDiscoveryResponse struct { + // Models Models the credential can reach, in the order the vendor returned them. + Models []AgentNetworkDiscoveredModel `json:"models"` +} + // AgentNetworkPolicy defines model for AgentNetworkPolicy. type AgentNetworkPolicy struct { // CreatedAt Timestamp when the policy was created. @@ -6179,6 +6227,9 @@ type PostApiAgentNetworkBudgetRulesJSONRequestBody = AgentNetworkBudgetRuleReque // PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody defines body for PutApiAgentNetworkBudgetRulesRuleId for application/json ContentType. type PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody = AgentNetworkBudgetRuleRequest +// PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody defines body for PostApiAgentNetworkCatalogProvidersModels for application/json ContentType. +type PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody = AgentNetworkModelDiscoveryRequest + // PostApiAgentNetworkGuardrailsJSONRequestBody defines body for PostApiAgentNetworkGuardrails for application/json ContentType. type PostApiAgentNetworkGuardrailsJSONRequestBody = AgentNetworkGuardrailRequest From f03853867b5a85919ed94933ed01dfa3d5d3e1b2 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 23 Aug 2026 20:29:10 +0200 Subject: [PATCH 33/36] [proxy,management] Serve Bedrock model discovery from the control plane (#7250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [proxy,management] Serve Bedrock model discovery from the control plane A Bedrock provider could never answer a model-discovery request. The router sent GET /inference-profiles to the record's upstream, which has to be bedrock-runtime. for InvokeModel to work, and that host does not implement the operation. ListInferenceProfiles is a control-plane operation on bedrock..amazonaws.com, and one provider record carries one upstream, so the two hosts genuinely differ. The route now carries a discovery host, taken from the catalog's declaration with the region read back out of the configured upstream, and the listing — and only the listing — goes there. Inference is untouched. A proxied or self-hosted Bedrock endpoint gets no discovery host at all rather than a guessed one, since inventing a host would send the operator's credential somewhere they never configured. Two things had to follow for the listing to be usable once it arrives. The response filter only understood OpenAI's {"data":[{"id":…}]}, so a Bedrock listing fell through it untouched, offering every profile in the account whatever the policy said. And discoverableModels intersected by exact string, so a record registering the raw profile id while a guardrail names the catalog key intersected to nothing — bounding a working provider's listing down to empty. Normalisation is the third. The geography in front of a cross-region profile was matched against a hardcoded list of four, so every profile issued under jp, au, ca, sa or us-gov carried its prefix into the pricing key, matched no catalog entry and metered at zero. It is now recognised by either the geography or the vendor that follows it, so an id has to be new on both axes at once to slip through — a live eu-central-1 listing returned "global.xai.grok-4.6" days after the vendor list was first written. --- .github/workflows/agent-network-e2e.yml | 13 +- e2e/agentnetwork/discovery_live_test.go | 101 +++++++--- .../agentnetwork/modeldiscovery/discovery.go | 6 +- .../modeldiscovery/discovery_test.go | 38 +++- .../modules/agentnetwork/synthesizer.go | 35 ++++ .../agentnetwork/synthesizer_pricing_test.go | 34 ++++ .../modules/agentnetwork/synthesizer_test.go | 55 ++++++ .../llm_router/bedrock_discovery_test.go | 175 ++++++++++++++++++ .../middleware/builtin/llm_router/factory.go | 7 + .../builtin/llm_router/middleware.go | 79 +++++++- proxy/internal/proxy/discovery_filter.go | 72 ++++--- proxy/internal/proxy/discovery_filter_test.go | 37 ++++ shared/llm/model.go | 92 ++++++++- shared/llm/model_test.go | 58 ++++++ 14 files changed, 730 insertions(+), 72 deletions(-) create mode 100644 proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index 88b98293d..9501c5fba 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -12,6 +12,13 @@ on: AWS issues it. Leave empty for the Sonnet 4.6 default. required: false default: "" + test_pattern: + description: >- + Package pattern to run. Defaults to the whole suite; narrow it to one + package (e.g. ./e2e/agentnetwork/...) when a run only needs that + package's answer and not the sixteen minutes the container suite costs. + required: false + default: "./e2e/..." concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -77,4 +84,8 @@ jobs: GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }} GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }} GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }} - run: go test -tags e2e -timeout 40m -v ./e2e/... + # Read through an env var rather than interpolated into the run + # script: a dispatch input reaching a shell command directly is a + # script-injection seam, however trusted the dispatcher. + TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }} + run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN" diff --git a/e2e/agentnetwork/discovery_live_test.go b/e2e/agentnetwork/discovery_live_test.go index 321e751bf..22c9f31c2 100644 --- a/e2e/agentnetwork/discovery_live_test.go +++ b/e2e/agentnetwork/discovery_live_test.go @@ -169,17 +169,15 @@ func liveDiscoveryCases() []liveDiscoveryCase { // Bedrock lists inference profiles, not models: matchModelless routes // /inference-profiles to a Bedrock route and refuses /v1/models for one. // - // The request reaches AWS and AWS refuses it — bedrock-runtime answers - // , because ListInferenceProfiles is a CONTROL - // PLANE operation served by bedrock..amazonaws.com, not the runtime - // host. A provider record carries one upstream and it has to be the runtime - // host for InvokeModel to work, so no Bedrock record can serve a listing as - // the model stands today. + // The listing is served by the CONTROL PLANE (bedrock.), not the + // runtime host a provider record must point at for InvokeModel — the + // runtime host answers . The router now sends + // the listing, and only the listing, to the control plane, so this case + // asserts a real filtered listing rather than the 404 it used to get. // - // The mock upstream hides this entirely: it answers /inference-profiles on - // the same listener as everything else, so the routing test passes there - // while the real endpoint 404s. That is the whole reason this file exists, - // so the case is kept, asserting what actually happens. + // The mock upstream cannot show any of this: it answers + // /inference-profiles on the same listener as everything else, so a + // mock-based test passes whichever host the request went to. if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { region := os.Getenv("AWS_REGION") if region == "" { @@ -192,9 +190,13 @@ func liveDiscoveryCases() []liveDiscoveryCase { cases = append(cases, liveDiscoveryCase{ name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, - path: "/inference-profiles", - models: []string{sharedllm.NormalizeAnthropicModel(strings.TrimPrefix(model, "global."))}, - outcome: outcomeUpstreamNoListing, + path: "/inference-profiles", + // Registered verbatim, as an operator would copy it from AWS: the + // region prefix is what makes the id invocable, and the listing + // returns ids in exactly this form. + models: []string{model}, + outcome: outcomeFiltered, + permitted: []string{model}, }) } @@ -323,13 +325,19 @@ func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCas code, body := callUntil(t, func() (int, string, error) { return cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) }, 200) - t.Logf("[discovery] %s GET %s -> %d; body: %s", tc.name, tc.path, code, truncate(body, 4000)) - require.Equal(t, 200, code, "%s discovery must be served; body: %s", tc.name, truncate(body, 2000)) + // Status only, not the body. A Bedrock listing embeds inference-profile + // ARNs carrying the 12-digit AWS account id, and these job logs are + // readable by anyone who can see the run. The ids line below is the finding + // anyway. The failure paths below are the same log: a listing that fails to + // arrive is an AWS refusal naming the resource it refused, and that name is + // an ARN carrying the same account id. + t.Logf("[discovery] %s GET %s -> %d", tc.name, tc.path, code) + require.Equal(t, 200, code, "%s discovery must be served; response was %s", tc.name, bodyShape(body)) ids, ok := listingIDs(body) require.Truef(t, ok, - "%s answered discovery with something other than a {\"data\":[{\"id\":…}]} listing, which the filter forwards untouched — the caller would get an unbounded picker; body: %s", - tc.name, truncate(body, 2000)) + "%s answered discovery with something other than a {\"data\":[{\"id\":…}]} listing, which the filter forwards untouched — the caller would get an unbounded picker; response was %s", + tc.name, bodyShape(body)) sort.Strings(ids) t.Logf("[discovery] %s: %d ids after filtering: %s", tc.name, len(ids), strings.Join(ids, ", ")) @@ -342,8 +350,11 @@ func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCas } for _, id := range ids { _, direct := permitted[id] - _, normalised := permitted[sharedllm.NormalizeAnthropicModel(id)] - assert.Truef(t, direct || normalised, + _, dated := permitted[sharedllm.NormalizeAnthropicModel(id)] + // Bedrock ids carry a region prefix and version suffix the record may + // not repeat; the proxy's filter tries the same forms. + _, bedrock := permitted[sharedllm.NormalizeBedrockModel(id)] + assert.Truef(t, direct || dated || bedrock, "%s offered %q, which no policy on this route permits — every entry the picker shows must be a request the guardrail would allow", tc.name, id) } for _, hidden := range tc.wantHidden { @@ -362,24 +373,39 @@ func isProxyError(body string) bool { } // listingIDs pulls the model ids out of a listing response. ok is false when -// the body is not the {"data":[{"id":…}]} shape the filter recognises. +// the body is neither envelope the proxy's filter recognises — the two must +// stay in step, or this test reports "not a listing" for a response the proxy +// filtered perfectly well. func listingIDs(body string) ([]string, bool) { var doc struct { + // OpenAI's shape, which Anthropic adopted. Data []struct { ID string `json:"id"` } `json:"data"` + // Bedrock returns inference-profile summaries under a key of its own, + // with the id under a field of its own. + Summaries []struct { + ID string `json:"inferenceProfileId"` + } `json:"inferenceProfileSummaries"` } if err := json.Unmarshal([]byte(body), &doc); err != nil { return nil, false } - if doc.Data == nil { - return nil, false + switch { + case doc.Data != nil: + ids := make([]string, 0, len(doc.Data)) + for _, entry := range doc.Data { + ids = append(ids, entry.ID) + } + return ids, true + case doc.Summaries != nil: + ids := make([]string, 0, len(doc.Summaries)) + for _, entry := range doc.Summaries { + ids = append(ids, entry.ID) + } + return ids, true } - ids := make([]string, 0, len(doc.Data)) - for _, entry := range doc.Data { - ids = append(ids, entry.ID) - } - return ids, true + return nil, false } func caseNames(cases []liveDiscoveryCase) []string { @@ -390,6 +416,27 @@ func caseNames(cases []liveDiscoveryCase) []string { return names } +// bodyShape describes a response without quoting any of it: its size and the +// top-level keys it arrived under. That is what a discovery failure is +// diagnosed from — which envelope the vendor answered with — and it is all +// that may go in a message rendered into a public job log, because the values +// underneath can carry an ARN and its account id. +func bodyShape(body string) string { + var doc map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return strconv.Itoa(len(body)) + " bytes, not a JSON object" + } + keys := make([]string, 0, len(doc)) + for key := range doc { + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) == 0 { + return strconv.Itoa(len(body)) + " bytes, an empty JSON object" + } + return strconv.Itoa(len(body)) + " bytes, keyed by: " + strings.Join(keys, ", ") +} + // truncate bounds a logged response body. A live catalogue can run to tens of // kilobytes, and the useful part is the front. func truncate(s string, limit int) string { diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go index 37401820c..253cc63b3 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go @@ -191,7 +191,7 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro // A provider record carries no region field: the region lives // inside the upstream host the operator already configured, so // read it back out rather than asking them for it twice. - region = regionFromUpstream(entry, req.UpstreamURL) + region = RegionFromUpstream(entry, req.UpstreamURL) } if region == "" { return "", fmt.Errorf("%w: %s discovery needs a region, and none could be read from the provider upstream", @@ -207,13 +207,13 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro return target.String(), nil } -// regionFromUpstream recovers the region an operator embedded in the provider +// RegionFromUpstream recovers the region an operator embedded in the provider // upstream, by matching it against the catalog's own host template. Bedrock's // template is "bedrock-runtime..amazonaws.com" and Vertex's is // "-aiplatform.googleapis.com", so the region is whatever sits between // the fixed halves. Returns empty when the upstream does not match the // template, which is the case for a custom or proxied endpoint. -func regionFromUpstream(entry catalog.Provider, upstreamURL string) string { +func RegionFromUpstream(entry catalog.Provider, upstreamURL string) string { prefix, suffix, found := strings.Cut(entry.DefaultHost, catalog.RegionPlaceholder) if !found { return "" diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go index fba2c97d1..133bd5148 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go @@ -490,7 +490,43 @@ func TestRegionFromUpstream(t *testing.T) { {"bedrock regionless without scheme", bedrock, "bedrock-runtime.amazonaws.com", ""}, } { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, regionFromUpstream(tc.entry, tc.upstream)) + assert.Equal(t, tc.want, RegionFromUpstream(tc.entry, tc.upstream)) }) } } + +// bedrockGeoListing carries profiles from geographies the original prefix list +// did not name. Every one reduces to a catalog key, so every one must arrive +// priced — an unstripped geography is what made a real account's listing come +// back almost entirely at zero. +const bedrockGeoListing = `{"inferenceProfileSummaries":[ + {"inferenceProfileId":"jp.anthropic.claude-sonnet-5-20260514-v1:0", + "inferenceProfileName":"JP Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"au.anthropic.claude-haiku-4-5-20251001-v1:0", + "inferenceProfileName":"AU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"us-gov.anthropic.claude-sonnet-5-20260514-v1:0", + "inferenceProfileName":"GovCloud Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"} +]}` + +func TestBedrockProfilesFromAnyGeographyArrivePriced(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, bedrockGeoListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com", + APIKey: "aws-token", + }) + require.NoError(t, err) + require.Len(t, models, 3) + + for _, m := range models { + assert.True(t, m.PricingKnown, "%s must resolve to a catalog rate", m.ID) + assert.Greater(t, m.InputPer1k, 0.0, "input rate for %s", m.ID) + assert.Greater(t, m.OutputPer1k, 0.0, "output rate for %s", m.ID) + assert.Greater(t, m.CacheReadPer1k, 0.0, "cache-read rate for %s", m.ID) + } + + // The wire id is preserved whatever the pricing key reduced to: it is the + // only form that works at invoke time. + assert.Equal(t, "jp.anthropic.claude-sonnet-5-20260514-v1:0", models[0].ID) +} diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 76944698e..66a19acd9 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" @@ -380,6 +381,9 @@ type routerProviderRoute struct { // proxy dials this provider's upstream. For self-hosted / internal gateways // behind a private or self-signed certificate. SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` + // DiscoveryHost, when set, is the host serving this provider's model + // listing, for a vendor that does not serve it from the inference host. + DiscoveryHost string `json:"discovery_host,omitempty"` } // indexProviderGroups walks the enabled policies and returns, per @@ -447,6 +451,9 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][] if err != nil { return nil, fmt.Errorf("router config for provider %s: %w", p.ID, err) } + // Lookup rather than assume: an unknown provider id yields the zero + // entry, which declares no discovery and so contributes nothing. + catalogEntry, _ := catalog.Lookup(p.ProviderID) headerName, headerValue, gcpSAKeyB64, err := providerAuthHeader(p) if err != nil { return nil, err @@ -466,6 +473,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][] Bedrock: catalog.IsBedrockPathStyle(p.ProviderID), GCPServiceAccountKeyB64: gcpSAKeyB64, SkipTLSVerify: p.SkipTLSVerification, + DiscoveryHost: discoveryHost(catalogEntry, p.UpstreamURL), }) } out, err := json.Marshal(cfg) @@ -475,6 +483,33 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][] return out, nil } +// discoveryHost returns the host serving this provider's model listing when it +// differs from the inference host, and empty when the two are the same — which +// is true of every vendor but Bedrock, whose ListInferenceProfiles is a control +// plane operation on bedrock. while InvokeModel must go to +// bedrock-runtime.. One provider record therefore needs two hosts. +// +// The catalog declares the listing host; the region is recovered from the +// upstream the operator configured, since a provider record carries no region +// field. An upstream matching no catalog template yields empty rather than a +// guess: a proxied or self-hosted Bedrock endpoint may serve both from one +// place, and inventing a host would send the credential somewhere the operator +// never configured. +func discoveryHost(entry catalog.Provider, upstreamURL string) string { + if entry.Discovery == nil || entry.Discovery.Host == "" { + return "" + } + host := entry.Discovery.Host + if !strings.Contains(host, catalog.RegionPlaceholder) { + return host + } + region := modeldiscovery.RegionFromUpstream(entry, upstreamURL) + if region == "" { + return "" + } + return strings.ReplaceAll(host, catalog.RegionPlaceholder, region) +} + // providerVendor returns the parser surface ("openai", "anthropic", …) // the provider speaks, sourced from its catalog entry's ParserID. The // router uses it to keep a request the parser tagged with a vendor on a diff --git a/management/internals/modules/agentnetwork/synthesizer_pricing_test.go b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go index 83961878a..e82f2ef05 100644 --- a/management/internals/modules/agentnetwork/synthesizer_pricing_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go @@ -103,3 +103,37 @@ func TestBuildCostMeterConfig_OrphanAndGatewayProviders(t *testing.T) { assert.NotContains(t, cfg.Pricing.Providers, "prov-litellm", "empty-models gateway needs no per-record entry") assert.NotEmpty(t, cfg.Pricing.Defaults["openai"], "defaults still ship so the gateway's catalog-model traffic is priced") } + +// TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour is the +// accounting half of the geography bug. The docs tell operators to register a +// Bedrock id exactly as AWS issues it, region prefix included, and the cost +// meter keys its table by the normalized form. While the geography was matched +// against a list of four, a profile issued anywhere else kept its prefix, +// missed the catalog entry it was meant to inherit from, and billed with a +// zero entry underneath the operator's own rates — so every cache bucket +// metered free and a model priced only by catalog defaults metered at nothing +// at all. +func TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour(t *testing.T) { + for _, geo := range []string{"jp", "au", "ca", "sa", "us-gov"} { + t.Run(geo, func(t *testing.T) { + bedrock := &types.Provider{ + ID: "prov-bedrock", + ProviderID: "bedrock_api", + Enabled: true, + Models: []types.ProviderModel{ + {ID: geo + ".anthropic.claude-sonnet-5-20260514-v1:0", InputPer1k: 0.003, OutputPer1k: 0.015}, + }, + } + raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}}) + require.NoError(t, err) + cfg := decodeCostMeterConfig(t, raw) + + e, ok := cfg.Pricing.Providers["prov-bedrock"]["anthropic.claude-sonnet-5"] + require.True(t, ok, "a %s profile must key by the same normalized id the parser emits", geo) + assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9, + "cache read must be inherited from the bedrock default entry, not left at zero") + assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9, + "cache creation must be inherited from the bedrock default entry, not left at zero") + }) + } +} diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 817129571..352d36646 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/store" @@ -1245,3 +1246,57 @@ func TestSynthesizeServices_EmptyAPIKey_FailsClosed(t *testing.T) { require.Error(t, err, "synthesis must refuse a provider with no api key") assert.Contains(t, err.Error(), "no api key", "error must surface the missing credential") } + +// TestDiscoveryHost pins which providers get a separate listing host. Getting +// this wrong in either direction is costly: a missing host leaves Bedrock +// discovery 404ing at AWS, and a host on the wrong provider would send that +// provider's listing — and its credential — somewhere the operator never +// configured. +func TestDiscoveryHost(t *testing.T) { + entry := func(id string) catalog.Provider { + p, ok := catalog.Lookup(id) + require.True(t, ok, "catalog entry %s must exist", id) + return p + } + + for _, tc := range []struct { + name string + entry catalog.Provider + upstream string + want string + }{ + { + // ListInferenceProfiles is a control-plane operation; the runtime + // host answers for it. + name: "bedrock splits the listing off the runtime host", + entry: entry("bedrock_api"), upstream: "https://bedrock-runtime.eu-central-1.amazonaws.com", + want: "bedrock.eu-central-1.amazonaws.com", + }, + { + name: "bedrock in another region", + entry: entry("bedrock_api"), upstream: "https://bedrock-runtime.us-west-2.amazonaws.com", + want: "bedrock.us-west-2.amazonaws.com", + }, + { + // A proxied Bedrock endpoint may well serve both from one place, + // and there is no region to read back out of it. + name: "proxied bedrock upstream yields no discovery host", + entry: entry("bedrock_api"), upstream: "https://bedrock.internal.example.com", + want: "", + }, + { + name: "openai serves its listing from the same host", + entry: entry("openai_api"), upstream: "https://api.openai.com", + want: "", + }, + { + name: "vertex serves its listing from the same host", + entry: entry("vertex_ai_api"), upstream: "https://us-east5-aiplatform.googleapis.com", + want: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, discoveryHost(tc.entry, tc.upstream)) + }) + } +} diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go new file mode 100644 index 000000000..d21e33c21 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go @@ -0,0 +1,175 @@ +package llm_router + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// bedrockRoute is a Bedrock provider whose listing lives on the control plane +// while inference goes to the runtime host — the split this file is about. +func bedrockRoute(models []string, policies []ModelPolicyRule) ProviderRoute { + return ProviderRoute{ + ID: "prov-bedrock", + Bedrock: true, + Models: models, + ModelPolicies: policies, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + DiscoveryHost: "bedrock.eu-central-1.amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer aws-token", + AllowedGroupIDs: []string{defaultTestGroup}, + } +} + +func getInput(path string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + Method: http.MethodGet, + URL: "https://endpoint.netbird.local" + path, + UserGroups: []string{defaultTestGroup}, + } +} + +// TestBedrockListingGoesToTheControlPlane is the whole point of DiscoveryHost. +// ListInferenceProfiles is not an operation bedrock-runtime implements — it +// answers — so a listing forwarded to the +// inference upstream can only 404, however well it is routed. +func TestBedrockListingGoesToTheControlPlane(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{bedrockRoute(nil, nil)}}) + + out, err := mw.Invoke(context.Background(), getInput("/inference-profiles")) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + + assert.Equal(t, "bedrock.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host) +} + +// TestBedrockInferenceStillGoesToTheRuntimeHost is the other half: the +// redirect must apply to the listing alone. Sending an InvokeModel call to the +// control plane would break every Bedrock request in the account. +func TestBedrockInferenceStillGoesToTheRuntimeHost(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{bedrockRoute(nil, nil)}}) + + in := newInputWithModelAndURL("anthropic.claude-haiku-4-5", + "https://endpoint.netbird.local/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/invoke") + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + require.NotNil(t, out.Mutations.RewriteUpstream) + + assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host) +} + +// TestIsListingPath guards the narrower reading of "model-less". Both the +// upstream redirect and the policy bound key on this, and the warming probe +// must be excluded from both: it carries no listing to filter, and pointing it +// at the control plane would warm a pool the inference requests never use. +func TestIsListingPath(t *testing.T) { + for path, want := range map[string]bool{ + "/v1/models": true, + "/inference-profiles": true, + "/bedrock/inference-profiles": true, + "/api/hello": false, + "/v1/models/gpt-4o": false, // the per-model lookup, routed elsewhere + "/v1/chat/completions": false, + } { + t.Run(path, func(t *testing.T) { + assert.Equal(t, want, isListingPath(path)) + }) + } +} + +// TestBedrockListingIsBoundByPolicy covers the case that was previously +// unreachable: filtering keyed on /v1/models alone, so a Bedrock listing was +// routed but never narrowed to what the caller may use. +func TestBedrockListingIsBoundByPolicy(t *testing.T) { + route := bedrockRoute( + []string{"eu.anthropic.claude-haiku-4-5-20251001-v1:0", "eu.anthropic.claude-sonnet-4-6"}, + []ModelPolicyRule{{ + GroupIDs: []string{defaultTestGroup}, + // A guardrail allowlist names the catalog key, which is the form an + // operator picks in the UI — not the region-prefixed wire id the + // record registers. + Models: []string{"anthropic.claude-haiku-4-5"}, + }}, + ) + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), getInput("/inference-profiles")) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream) + + // Exact-string intersection would find nothing here and bound the listing + // to empty, handing the caller a picker with no models on a provider that + // works perfectly well. + assert.Equal(t, []string{"eu.anthropic.claude-haiku-4-5-20251001-v1:0"}, + out.Mutations.RewriteUpstream.DiscoveryModels) +} + +// TestBedrockListingWithoutADiscoveryHostFallsThrough keeps a proxied or +// self-hosted Bedrock endpoint working: the synthesiser emits no discovery +// host for one, and the listing must then go to the configured upstream rather +// than nowhere. +func TestBedrockListingWithoutADiscoveryHostFallsThrough(t *testing.T) { + route := bedrockRoute(nil, nil) + route.UpstreamHost = "bedrock.internal.example.com" + route.DiscoveryHost = "" + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), getInput("/inference-profiles")) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream) + + assert.Equal(t, "bedrock.internal.example.com", out.Mutations.RewriteUpstream.Host) +} + +// TestBedrockProfileDetailHonoursTheModelTable covers GetInferenceProfile, +// which the listing filter cannot help with: it answers for one profile with a +// single object, not a set, so nothing narrows it on the way back. Authorising +// it by provider type alone would let any caller with a Bedrock route read the +// full configuration of every profile in the account. +// +// Both registration spellings are exercised, because a record may carry the +// raw profile id AWS issues or the catalog key it reduces to. +func TestBedrockProfileDetailHonoursTheModelTable(t *testing.T) { + const permitted = "eu.anthropic.claude-sonnet-5-20260514-v1:0" + + for _, registered := range []string{permitted, "anthropic.claude-sonnet-5"} { + t.Run(registered, func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{bedrockRoute([]string{registered}, nil)}}) + + out, err := mw.Invoke(context.Background(), getInput("/inference-profiles/"+permitted)) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a profile the record registers must still resolve") + + denied, err := mw.Invoke(context.Background(), + getInput("/inference-profiles/eu.anthropic.claude-opus-5-20260514-v1:0")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, denied.Decision, + "a profile outside the record's models must not be readable") + }) + } +} + +// TestBedrockProfileListingStaysModelLess pins the other half: the listing +// names no profile, so it must not be judged against the model table. It is +// bounded by DiscoveryModels in the response instead, and denying it here +// would take model discovery away from exactly the records that enumerate +// their models. +func TestBedrockProfileListingStaysModelLess(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{bedrockRoute([]string{"anthropic.claude-sonnet-5"}, nil)}}) + + out, err := mw.Invoke(context.Background(), getInput("/inference-profiles")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision) +} diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go index ae3d44a40..81b8727f1 100644 --- a/proxy/internal/middleware/builtin/llm_router/factory.go +++ b/proxy/internal/middleware/builtin/llm_router/factory.go @@ -50,6 +50,13 @@ type ProviderRoute struct { // under different allowlists must not offer either group the other's // models. Empty means no policy restricts models on this route. ModelPolicies []ModelPolicyRule `json:"model_policies,omitempty"` + // DiscoveryHost, when set, is the host that serves this provider's model + // listing, for a vendor that does not serve it from the same host as + // inference. Bedrock is why it exists: ListInferenceProfiles is a control + // plane operation on bedrock., while InvokeModel must go to + // bedrock-runtime., so one record genuinely needs two hosts. + // Empty means the listing is served from UpstreamHost like everything else. + DiscoveryHost string `json:"discovery_host,omitempty"` // Vertex marks a Google Vertex AI provider. Vertex requests carry the // model in the URL path, so the router selects this route by path // (isVertexPath) and bypasses the model/vendor table entirely. diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 01981666c..b8d4b001b 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -242,10 +242,16 @@ func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix { stripBedrockNamespace(out) } - // What the caller may actually use bounds what the picker may offer: - // every entry outside it is a request the chain will deny a moment - // later. - if reqPath == modelListingPath && out.Mutations != nil && out.Mutations.RewriteUpstream != nil { + if isListingPath(reqPath) && out.Mutations != nil && out.Mutations.RewriteUpstream != nil { + // A vendor that serves its listing from somewhere other than its + // inference upstream is redirected here, and only for the listing + // — every other request still goes to the configured upstream. + if route.DiscoveryHost != "" { + out.Mutations.RewriteUpstream.Host = route.DiscoveryHost + } + // What the caller may actually use bounds what the picker may + // offer: every entry outside it is a request the chain will deny a + // moment later. if models, bounded := discoverableModels(route, userGroups); bounded { out.Mutations.RewriteUpstream.DiscoveryModels = models } @@ -310,6 +316,20 @@ func discoverableModels(route ProviderRoute, userGroups []string) ([]string, boo for _, m := range route.Models { if _, ok := permitted[m]; ok { intersection[m] = struct{}{} + continue + } + // The two sides are not always written the same way. A Bedrock record + // may register the raw inference-profile id an operator copied from + // AWS while a guardrail allowlist names the catalog key, and comparing + // those verbatim finds nothing — which would bound a correctly + // configured provider's listing down to empty. routeClaimsModel + // already normalises the candidate for exactly this reason, and the + // listing bound has to agree with it or the picker disagrees with what + // the guardrail will actually allow. + if route.Bedrock { + if _, ok := permitted[llm.NormalizeBedrockModel(m)]; ok { + intersection[m] = struct{}{} + } } } return sortedModels(intersection), true @@ -472,6 +492,14 @@ const connectionWarmPath = "/api/hello" // alone. const modelListingPath = "/v1/models" +// isListingPath reports whether reqPath asks for a MODEL LISTING, as opposed +// to the other model-less endpoints. Only a listing gets an upstream redirect +// and a policy bound: the connection-warming probe carries no model list to +// filter, and rewriting its host would send the warm-up to the wrong pool. +func isListingPath(reqPath string) bool { + return reqPath == modelListingPath || isBedrockModelLessPath(reqPath) +} + // isModelLessPath reports whether reqPath is a known non-inference endpoint // that legitimately carries no model at all: the model listing and the // connection-warming probe. These must route to an upstream rather than @@ -513,7 +541,30 @@ func modelDetailID(reqPath string) (string, bool) { // gateway that does serve the lookup get a working answer. func isBedrockModelLessPath(reqPath string) bool { native, _ := splitBedrockNamespace(reqPath) - return native == "/inference-profiles" || strings.HasPrefix(native, "/inference-profiles/") + return native == "/inference-profiles" || strings.HasPrefix(native, bedrockProfileDetailPrefix) +} + +// bedrockProfileDetailPrefix precedes the identifier in a GetInferenceProfile +// lookup, once any gateway namespace is off the front. +const bedrockProfileDetailPrefix = "/inference-profiles/" + +// bedrockProfileID returns the inference profile a "/inference-profiles/{id}" +// lookup names. The listing beside it names none, which is what separates the +// two: a listing is a set the response filter can bound, while this answers +// for one profile with a single object no filter inspects. +// +// The id arrives as AWS issues it — region prefix and version suffix included +// — because that is the only form that works at invoke time. +func bedrockProfileID(reqPath string) (string, bool) { + native, _ := splitBedrockNamespace(reqPath) + if !strings.HasPrefix(native, bedrockProfileDetailPrefix) { + return "", false + } + id := strings.TrimPrefix(native, bedrockProfileDetailPrefix) + if id == "" { + return "", false + } + return id, true } // isVertexPath reports whether reqPath is a Google Vertex AI publisher @@ -653,7 +704,23 @@ func (m *Middleware) matchModelless(reqPath, method string, userGroups []string) var eligible func(ProviderRoute) bool switch { case isBedrockModelLessPath(reqPath): - eligible = func(r ProviderRoute) bool { return r.Bedrock } + if profile, isDetail := bedrockProfileID(reqPath); isDetail { + // A detail lookup names one profile, so it is authorised like any + // other per-model request rather than by provider type alone. The + // listing beside it is bounded by DiscoveryModels on the way back, + // but this answers with a single object no filter inspects — so + // without the check here, a caller reads the full configuration of + // every profile in the account, including the ones its policy + // never named. + // + // The id is normalised first: a record may register the raw + // profile id or the catalog key it reduces to, and routeClaimsModel + // expects the normalised form an inference request would carry. + wanted := llm.NormalizeBedrockModel(profile) + eligible = func(r ProviderRoute) bool { return r.Bedrock && routeClaimsModel(r, wanted) } + } else { + eligible = func(r ProviderRoute) bool { return r.Bedrock } + } case isModelLessPath(reqPath): // Vertex/Bedrock are path-routed and don't serve OpenAI-style // model-listing endpoints; including them here could rewrite a diff --git a/proxy/internal/proxy/discovery_filter.go b/proxy/internal/proxy/discovery_filter.go index c9d606970..d5502e1f1 100644 --- a/proxy/internal/proxy/discovery_filter.go +++ b/proxy/internal/proxy/discovery_filter.go @@ -97,6 +97,18 @@ func isPlainJSONListing(resp *http.Response) bool { return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json") } +// listingEnvelopes maps a listing's wrapper key to the field naming the model +// id inside it. Vendors did not converge on one shape: OpenAI's is what +// Anthropic adopted, while Bedrock returns inference-profile summaries under a +// key of its own. A body matching none of these is forwarded untouched. +var listingEnvelopes = []struct { + key string + idField string +}{ + {"data", "id"}, + {"inferenceProfileSummaries", "inferenceProfileId"}, +} + // filterListingBody returns the listing with unauthorised entries removed. // ok is false when the body is not a listing shape, in which case the // caller must forward the original bytes. @@ -105,38 +117,41 @@ func filterListingBody(body []byte, permitted map[string]struct{}) ([]byte, bool if err := json.Unmarshal(body, &doc); err != nil { return nil, false } - raw, present := doc["data"] - if !present { - return nil, false - } - var entries []map[string]json.RawMessage - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, false - } - - kept := make([]map[string]json.RawMessage, 0, len(entries)) - for _, entry := range entries { - if entryPermitted(entry, permitted) { - kept = append(kept, entry) + for _, envelope := range listingEnvelopes { + raw, present := doc[envelope.key] + if !present { + continue + } + var entries []map[string]json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, false } - } - encoded, err := json.Marshal(kept) - if err != nil { - return nil, false + kept := make([]map[string]json.RawMessage, 0, len(entries)) + for _, entry := range entries { + if entryPermitted(entry, envelope.idField, permitted) { + kept = append(kept, entry) + } + } + + encoded, err := json.Marshal(kept) + if err != nil { + return nil, false + } + doc[envelope.key] = encoded + out, err := json.Marshal(doc) + if err != nil { + return nil, false + } + return out, true } - doc["data"] = encoded - out, err := json.Marshal(doc) - if err != nil { - return nil, false - } - return out, true + return nil, false } // entryPermitted reports whether a listing entry names a model the policy // authorises, trying every form the same model is written in. -func entryPermitted(entry map[string]json.RawMessage, permitted map[string]struct{}) bool { - raw, ok := entry["id"] +func entryPermitted(entry map[string]json.RawMessage, idField string, permitted map[string]struct{}) bool { + raw, ok := entry[idField] if !ok { return false } @@ -184,6 +199,13 @@ func modelIDForms(id string) []string { return nil } forms := []string{id, sharedllm.NormalizeAnthropicModel(id)} + // A Bedrock listing returns region-prefixed, version-suffixed profile ids + // ("eu.anthropic.claude-haiku-4-5-20251001-v1:0") while the record may + // register the catalog key. Stripping to the key is a no-op for ids that + // carry neither, so this costs nothing on the other surfaces. + if bedrock := sharedllm.NormalizeBedrockModel(id); bedrock != id { + forms = append(forms, bedrock) + } if slash := strings.Index(id, "/"); slash > 0 { if _, ok := gatewayNamespaces[id[:slash]]; ok { tail := id[slash+1:] diff --git a/proxy/internal/proxy/discovery_filter_test.go b/proxy/internal/proxy/discovery_filter_test.go index 103eac594..fd5666345 100644 --- a/proxy/internal/proxy/discovery_filter_test.go +++ b/proxy/internal/proxy/discovery_filter_test.go @@ -233,3 +233,40 @@ func TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders(t *testing.T) { assert.Equal(t, strconv.Itoa(len(body)), resp.Header.Get("Content-Length"), "the Content-Length header must not be rewritten to the truncated prefix") } + +// TestFilterBedrockInferenceProfiles covers the second listing envelope. AWS +// returns inference-profile summaries under a key of its own with an id field +// of its own, so a filter that only knew OpenAI's shape forwarded a Bedrock +// listing whole — offering every profile in the account regardless of policy. +func TestFilterBedrockInferenceProfiles(t *testing.T) { + body := []byte(`{"inferenceProfileSummaries":[ + {"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0","status":"ACTIVE"}, + {"inferenceProfileId":"eu.anthropic.claude-sonnet-4-6","status":"ACTIVE"}, + {"inferenceProfileId":"global.cohere.embed-v4:0","status":"ACTIVE"} + ]}`) + + // The permitted set holds what the record registers. Here that is the + // catalog key, while the vendor answers with region-prefixed wire ids — + // the two must still line up. + permitted := map[string]struct{}{"anthropic.claude-haiku-4-5": {}} + + out, ok := filterListingBody(body, permitted) + require.True(t, ok, "a Bedrock listing must be recognised as filterable") + + var doc struct { + Summaries []struct { + ID string `json:"inferenceProfileId"` + } `json:"inferenceProfileSummaries"` + } + require.NoError(t, json.Unmarshal(out, &doc)) + require.Len(t, doc.Summaries, 1) + assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", doc.Summaries[0].ID) +} + +// TestFilterLeavesUnknownEnvelopesAlone keeps the best-effort contract: a body +// the filter cannot parse must reach the client exactly as the upstream sent +// it, rather than being rewritten into something shorter and wrong. +func TestFilterLeavesUnknownEnvelopesAlone(t *testing.T) { + _, ok := filterListingBody([]byte(`{"models":[{"name":"something"}]}`), map[string]struct{}{}) + assert.False(t, ok) +} diff --git a/shared/llm/model.go b/shared/llm/model.go index 4fb631520..881097bda 100644 --- a/shared/llm/model.go +++ b/shared/llm/model.go @@ -10,9 +10,88 @@ import ( "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."} +// bedrockVendorNamespaces are the vendor segments a Bedrock model id is +// published under. They identify the geography in front of a cross-region +// inference profile without knowing the geography: in +// "eu.anthropic.claude-...", what makes "eu" a geography is that "anthropic" +// follows it. +// +// A vendor missing from here is not fatal — bedrockGeographies covers the +// same id from the other side — but it is one of the two ways an id can go +// unrecognised, and the list needs a new entry whenever AWS onboards a +// vendor. A live listing found "global.xai.grok-4.6" days after this was +// first written. +var bedrockVendorNamespaces = map[string]struct{}{ + "ai21": {}, + "amazon": {}, + "anthropic": {}, + "cohere": {}, + "deepseek": {}, + "luma": {}, + "meta": {}, + "mistral": {}, + "openai": {}, + "qwen": {}, + "stability": {}, + "twelvelabs": {}, + "writer": {}, + "xai": {}, +} + +// bedrockGeographies are the geography segments AWS issues cross-region +// inference profiles under. They recognise a profile whose vendor we have +// never seen, which is the case bedrockVendorNamespaces alone gets wrong: +// "global.xai.grok-4.6" is a geography and a model whether or not "xai" is +// a name we know. +// +// Neither list is sufficient alone. A geography list on its own is what this +// file started with, and it aged badly — it held us, eu, apac and global, so +// every profile issued under jp, au, ca, sa or us-gov carried its prefix into +// the pricing key, matched no catalog entry, and reported the model unpriced. +// A vendor list on its own misses a new vendor under a known geography. +// Together, an id has to be new on both axes at once to go unrecognised. +var bedrockGeographies = map[string]struct{}{ + "apac": {}, + "au": {}, + "ca": {}, + "eu": {}, + "global": {}, + "jp": {}, + "sa": {}, + "us": {}, + "us-gov": {}, +} + +// stripBedrockGeography removes the cross-region inference-profile geography +// from a Bedrock model id, leaving the "." form the catalog and +// the pricing table key on. +// +// A leading segment counts as a geography when it is one we know, or when a +// known vendor follows it. Either alone is enough: the id has to be new on +// both axes before its geography survives. +// +// The segment has to be followed by two more, so "amazon.nova-pro" stays a +// vendor and a model rather than becoming a geography and a model — cutting +// its first segment would strip the vendor away. Over-stripping is the +// dangerous direction, because the result also decides which route may claim +// a model. +func stripBedrockGeography(modelID string) string { + geo, rest, found := strings.Cut(modelID, ".") + if !found || geo == "" { + return modelID + } + vendor, _, found := strings.Cut(rest, ".") + if !found { + return modelID + } + if _, ok := bedrockGeographies[geo]; ok { + return rest + } + if _, ok := bedrockVendorNamespaces[vendor]; ok { + return rest + } + return modelID +} // bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" // version/throughput suffix of a Bedrock model id. @@ -37,12 +116,7 @@ func NormalizeBedrockModel(modelID string) string { m = m[i+1:] } } - for _, p := range bedrockRegionPrefixes { - if strings.HasPrefix(m, p) { - m = m[len(p):] - break - } - } + m = stripBedrockGeography(m) return bedrockVersionSuffix.ReplaceAllString(m, "") } diff --git a/shared/llm/model_test.go b/shared/llm/model_test.go index 5ce2ff497..077a650fb 100644 --- a/shared/llm/model_test.go +++ b/shared/llm/model_test.go @@ -60,3 +60,61 @@ func TestNormalizeAnthropicModel(t *testing.T) { require.Equal(t, want, NormalizeAnthropicModel(in), "normalize %q", in) } } + +// TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour covers the bug +// that made this vendor-anchored: the geography used to be matched against a +// list of four, so a profile issued anywhere else kept its prefix, missed the +// catalog key it was supposed to match, and reported the model unpriced. +func TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour(t *testing.T) { + for _, geo := range []string{"us", "eu", "apac", "global", "jp", "au", "ca", "sa", "us-gov", "il", "mx"} { + t.Run(geo, func(t *testing.T) { + got := NormalizeBedrockModel(geo + ".anthropic.claude-sonnet-5-20260514-v1:0") + require.Equal(t, "anthropic.claude-sonnet-5", got, + "a cross-region profile must reduce to the catalog key whatever geography issued it") + }) + } +} + +// TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography pins the +// direction that must never break: a plain "." id has no +// geography, and cutting its first segment would strip the vendor away and +// hand the id to whichever route claims the bare model name. +func TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography(t *testing.T) { + cases := map[string]string{ + "amazon.nova-pro-v1:0": "amazon.nova-pro", + "anthropic.claude-sonnet-5-v1:0": "anthropic.claude-sonnet-5", + "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", + "cohere.command-r-plus-v1:0": "cohere.command-r-plus", + // Unknown on both axes: neither the leading segment nor the one + // after it is a name we hold, so the id is left exactly as it came. + "xx.unknownvendor.some-model-v1:0": "xx.unknownvendor.some-model", + "Qwen/Qwen2.5-0.5B-Instruct": "Qwen/Qwen2.5-0.5B-Instruct", + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + require.Equal(t, want, NormalizeBedrockModel(in)) + }) + } +} + +// TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis covers what a live +// eu-central-1 listing returned days after the vendor list was written: +// "global.xai.grok-4.6", a vendor the list did not hold. Anchoring only on the +// vendor left the geography in the key, so the id matched no catalog entry and +// the model metered at zero. Each id below is unfamiliar on one axis and +// recognised through the other. +func TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis(t *testing.T) { + cases := map[string]string{ + // Known geography, vendor we had never seen (the live case). + "global.xai.grok-4.6": "xai.grok-4.6", + "eu.xai.grok-4.6": "xai.grok-4.6", + // Known vendor, geography outside the list. + "il.anthropic.claude-sonnet-5-20260514-v1:0": "anthropic.claude-sonnet-5", + "mx.amazon.nova-2-lite-v1:0": "amazon.nova-2-lite", + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + require.Equal(t, want, NormalizeBedrockModel(in)) + }) + } +} From 7f03a2e86fe42f2418b1637ae0d00f3dae4351c3 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:54:11 +0900 Subject: [PATCH 34/36] [client] Hold a peer offer or answer that arrives before the handshaker starts listening (#7255) --- client/internal/peer/handshaker.go | 62 ++++++++++++++---------- client/internal/peer/handshaker_test.go | 63 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 client/internal/peer/handshaker_test.go diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 56e82e6e3..6ecb2a947 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -81,14 +81,19 @@ type Handshaker struct { func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker { h := &Handshaker{ - log: log, - config: config, - signaler: signaler, - ice: ice, - relay: relay, - metricsStages: metricsStages, - remoteOffersCh: make(chan OfferAnswer), - remoteAnswerCh: make(chan OfferAnswer), + log: log, + config: config, + signaler: signaler, + ice: ice, + relay: relay, + metricsStages: metricsStages, + // Buffered by one so an offer or answer that arrives between Open launching + // the Listen goroutine and it reaching its receive is held rather than + // dropped. A peer activated by an incoming signal receives the remote's + // message in that window; an unbuffered channel skips it as "receiver not + // ready", and the connection cannot proceed until the remote re-sends. + remoteOffersCh: make(chan OfferAnswer, 1), + remoteAnswerCh: make(chan OfferAnswer, 1), } // assume remote supports ICE until we learn otherwise from received offers h.remoteICESupported.Store(ice != nil) @@ -162,29 +167,38 @@ func (h *Handshaker) SendOffer() error { return h.sendOffer() } -// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) { - select { - case h.remoteOffersCh <- offer: - return - default: - h.log.Warnf("skipping remote offer message because receiver not ready") - // connection might not be ready yet to receive so we ignore the message - return - } + enqueueLatest(h.remoteOffersCh, offer) } -// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) { + enqueueLatest(h.remoteAnswerCh, answer) +} + +// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot +// already holds an unread message the older one is discarded in favor of msg, so a +// message arriving before Listen starts reading is held rather than dropped, and +// the newest wins if several arrive first. Safe because there is a single producer +// (the engine loop): after draining the stale value the send always has room. +func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) { select { - case h.remoteAnswerCh <- answer: + case ch <- msg: return default: - // connection might not be ready yet to receive so we ignore the message - h.log.Warnf("skipping remote answer message because receiver not ready") - return + } + + select { + case <-ch: + default: + } + + select { + case ch <- msg: + default: } } diff --git a/client/internal/peer/handshaker_test.go b/client/internal/peer/handshaker_test.go new file mode 100644 index 000000000..5e203d78b --- /dev/null +++ b/client/internal/peer/handshaker_test.go @@ -0,0 +1,63 @@ +package peer + +import ( + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func newTestHandshaker(t *testing.T) *Handshaker { + t.Helper() + // The tests exercise the answer path, whose Listen branch dispatches to the + // relay listener without sending an answer, so no signaler/ICE/relay is needed. + return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil) +} + +// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is +// activated by an incoming signal: the remote's offer/answer arrives in the same +// step that opens the connection, before the Listen loop starts reading. The +// message must be held rather than dropped, or the connection cannot proceed until +// the remote re-sends. This is the path taken when an eager peer connects to a +// lazily-managed one. +func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + // Delivered before Listen is reading, as when the peer is woken by the remote's + // signal and the message is delivered right after Open. + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820}) + + go h.Listen(t.Context()) + + select { + case <-processed: + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped") + } +} + +// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving +// before Listen reads: the newest must win (matching the latest-offer contract), +// rather than the first being kept and later ones discarded. +func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111}) + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222}) + + go h.Listen(t.Context()) + + select { + case got := <-processed: + assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed") + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: queued signal was dropped") + } +} From 5fc191167d6e736cd60fb325b704feda05a60b4f Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:47:41 +0900 Subject: [PATCH 35/36] [client] Revert declaring multi-buffer support for the loopback XDP program (#7303) --- client/internal/ebpf/ebpf/manager_linux.go | 47 ++++------------------ 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 64a3e5b54..7520a6387 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -2,21 +2,17 @@ package ebpf import ( _ "embed" - "fmt" "net" "sync" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/rlimit" log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" "github.com/netbirdio/netbird/client/internal/ebpf/manager" ) const ( - xdpProgName = "nb_xdp_prog" - mapKeyFeatures uint32 = 0 featureFlagWGProxy = 0b00000001 @@ -72,50 +68,21 @@ func (tf *GeneralManager) loadXdp() error { return err } - // lo has no native XDP, so the program runs in generic mode. Unless it - // declares multi-buffer support the kernel must linearize every non-linear - // skb before running it. Loopback packets are up to 64 KB, so that is a - // contiguous GFP_ATOMIC allocation per packet, and when it fails the packet - // is dropped before the program runs, stalling local TCP connections. - // Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a - // plain attach when the kernel rejects it. - err = tf.attachXdp(iFace.Index, true) - if err == nil { - return nil - } - log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err) - - return tf.attachXdp(iFace.Index, false) -} - -func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error { - spec, err := loadBpf() + // load pre-compiled programs into the kernel. + err = loadBpfObjects(&tf.bpfObjs, nil) if err != nil { - return fmt.Errorf("load bpf spec: %w", err) - } - - if multiBuffer { - prog, ok := spec.Programs[xdpProgName] - if !ok { - return fmt.Errorf("program %s not found in bpf spec", xdpProgName) - } - prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS - } - - if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil { - return fmt.Errorf("load bpf objects: %w", err) + return err } tf.link, err = link.AttachXDP(link.XDPOptions{ Program: tf.bpfObjs.NbXdpProg, - Interface: iFaceIndex, + Interface: iFace.Index, }) + if err != nil { - if closeErr := tf.bpfObjs.Close(); closeErr != nil { - log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr) - } + _ = tf.bpfObjs.Close() tf.link = nil - return fmt.Errorf("attach xdp: %w", err) + return err } return nil } From 3f90181f355f37e86e11de4dfe32f640a4f6aee8 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 24 Aug 2026 14:11:02 +0200 Subject: [PATCH 36/36] [ci] Remove mobile build validation workflow (#7302) The Android and iOS library builds now run in the android-client and ios-client repositories, so this workflow duplicates them. --- .github/workflows/mobile-build-validation.yml | 88 ------------------- 1 file changed, 88 deletions(-) delete mode 100644 .github/workflows/mobile-build-validation.yml diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml deleted file mode 100644 index 204576d28..000000000 --- a/.github/workflows/mobile-build-validation.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Mobile - -on: - push: - branches: - - main - - "release-*" - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} - cancel-in-progress: true - -jobs: - android_build: - name: "Android / Build" - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Install Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: "go.mod" - - name: Setup Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - with: - cmdline-tools-version: 8512546 - - name: Setup Java - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 - with: - java-version: "11" - distribution: "adopt" - - name: NDK Cache - id: ndk-cache - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: /usr/local/lib/android/sdk/ndk - key: ndk-cache-23.1.7779620 - - name: Setup NDK - run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - # `gomobile init` re-installs gobind from golang.org/x/mobile@latest - # regardless of the pin above (cmd/gomobile/init.go: "Make sure gobind is - # up to date"), so this step resolves a version nobody chose, on every run. - # - # setup-go sets GOTOOLCHAIN=local, so that install fails outright once - # x/mobile@latest declares a newer Go than go.mod does — which it did on - # 2026-08-21, breaking both jobs on every branch at once. GOTOOLCHAIN=auto - # lets this one install fetch the toolchain it asks for. Scoped to the - # step: the repo's own Go version, and every build below, is unaffected. - - name: gomobile init - run: gomobile init - env: - GOTOOLCHAIN: auto - - name: build android netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android - env: - CGO_ENABLED: 0 - ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/23.1.7779620 - ios_build: - name: "iOS / Build" - runs-on: macos-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Install Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: "go.mod" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - # See the Android job: `gomobile init` re-installs gobind from - # golang.org/x/mobile@latest regardless of the pin above, and needs a - # toolchain it may pick newer than go.mod's. - - name: gomobile init - run: gomobile init - env: - GOTOOLCHAIN: auto - - name: build iOS netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK - env: - CGO_ENABLED: 0