From 4268a5cfb7046bf713feac4b862539d20769d944 Mon Sep 17 00:00:00 2001 From: Lauri Tirkkonen Date: Tue, 5 May 2026 01:24:52 +0900 Subject: [PATCH 1/6] [client] Use atomic write/rename pattern for ssh config --- client/ssh/config/manager.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 6e584b2c3..5d69fd35c 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -224,15 +224,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { func (m *Manager) writeSSHConfig(sshConfig string) error { sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile) + sshConfigPathTmp := sshConfigPath + ".tmp" if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil { return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err) } - if err := writeFileWithTimeout(sshConfigPath, []byte(sshConfig), 0644); err != nil { + if err := writeFileWithTimeout(sshConfigPathTmp, []byte(sshConfig), 0644); err != nil { return fmt.Errorf("write SSH config file %s: %w", sshConfigPath, err) } + if err := os.Rename(sshConfigPathTmp, sshConfigPath); err != nil { + return fmt.Errorf("rename ssh config %s -> %s: %w", sshConfigPathTmp, sshConfigPath, err) + } + log.Infof("Created NetBird SSH client config: %s", sshConfigPath) return nil } From bde632c3b2f4fbf1252f0b37209f000661f466d9 Mon Sep 17 00:00:00 2001 From: alexsavio Date: Mon, 4 May 2026 18:49:39 +0200 Subject: [PATCH 2/6] [client] Replace WG interface monitor polling with netlink subscription on Linux (#5857) --- client/internal/wg_iface_monitor.go | 31 +---- client/internal/wg_iface_monitor_linux.go | 134 ++++++++++++++++++++++ client/internal/wg_iface_monitor_other.go | 56 +++++++++ 3 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 client/internal/wg_iface_monitor_linux.go create mode 100644 client/internal/wg_iface_monitor_other.go diff --git a/client/internal/wg_iface_monitor.go b/client/internal/wg_iface_monitor.go index a870c1145..2a2fa2366 100644 --- a/client/internal/wg_iface_monitor.go +++ b/client/internal/wg_iface_monitor.go @@ -6,7 +6,6 @@ import ( "fmt" "net" "runtime" - "time" log "github.com/sirupsen/logrus" @@ -28,6 +27,10 @@ func NewWGIfaceMonitor() *WGIfaceMonitor { // Start begins monitoring the WireGuard interface. // It relies on the provided context cancellation to stop. +// +// On Linux the watcher is event-driven (RTNLGRP_LINK netlink subscription) +// to avoid the allocation churn of repeatedly dumping the kernel link +// table; on other platforms it falls back to a low-frequency poll. func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRestart bool, err error) { defer close(m.done) @@ -56,31 +59,7 @@ func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRes log.Infof("Interface monitor: watching %s (index: %d)", ifaceName, expectedIndex) - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Infof("Interface monitor: stopped for %s", ifaceName) - return false, fmt.Errorf("wg interface monitor stopped: %v", ctx.Err()) - case <-ticker.C: - currentIndex, err := getInterfaceIndex(ifaceName) - if err != nil { - // Interface was deleted - log.Infof("Interface monitor: %s deleted", ifaceName) - return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) - } - - // Check if interface index changed (interface was recreated) - if currentIndex != expectedIndex { - log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", - ifaceName, expectedIndex, currentIndex) - return true, nil - } - } - } - + return watchInterface(ctx, ifaceName, expectedIndex) } // getInterfaceIndex returns the index of a network interface by name. diff --git a/client/internal/wg_iface_monitor_linux.go b/client/internal/wg_iface_monitor_linux.go new file mode 100644 index 000000000..2662b99d6 --- /dev/null +++ b/client/internal/wg_iface_monitor_linux.go @@ -0,0 +1,134 @@ +//go:build linux + +package internal + +import ( + "context" + "fmt" + "syscall" + + log "github.com/sirupsen/logrus" + "github.com/vishvananda/netlink" +) + +// watchInterface uses an RTNLGRP_LINK netlink subscription to detect +// deletion or recreation of the WireGuard interface. +// +// The previous implementation polled net.InterfaceByName every 2 s, which +// on Linux issues syscall.NetlinkRIB(RTM_GETLINK, ...) and dumps the +// entire kernel link table on every call. On hosts with many veth +// interfaces (containers, bridges) the resulting allocation churn was on +// the order of ~1 GB/day from this single ticker, which on small ARM +// hosts manifested as a slow RSS climb (see netbirdio/netbird#3678). +// +// The event-driven version below allocates only when the kernel actually +// publishes a link event for the tracked interface — typically zero +// allocations between events. +func watchInterface(ctx context.Context, ifaceName string, expectedIndex int) (bool, error) { + done := make(chan struct{}) + defer close(done) + + // Buffer the channel to absorb event bursts (e.g. when many veth + // pairs are created/destroyed at once by container runtimes). + linkChan := make(chan netlink.LinkUpdate, 32) + if err := netlink.LinkSubscribe(linkChan, done); err != nil { + // Return shouldRestart=true so the engine recovers monitoring + // via triggerClientRestart instead of silently losing it for + // the rest of the process lifetime. + return true, fmt.Errorf("subscribe to link updates: %w", err) + } + + // Race window: the interface could have been deleted (or recreated) + // between the initial getInterfaceIndex() in Start and LinkSubscribe + // completing its handshake with the kernel. Re-check explicitly so we + // do not block forever waiting for an event that already fired. + if currentIndex, err := getInterfaceIndex(ifaceName); err != nil { + log.Infof("Interface monitor: %s deleted before subscription completed", ifaceName) + return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) + } else if currentIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d) before subscription completed", + ifaceName, expectedIndex, currentIndex) + return true, nil + } + + for { + select { + case <-ctx.Done(): + log.Infof("Interface monitor: stopped for %s", ifaceName) + return false, fmt.Errorf("wg interface monitor stopped: %w", ctx.Err()) + + case update, ok := <-linkChan: + if !ok { + // The vishvananda/netlink subscription goroutine closes + // the channel on receive errors. Signal the engine to + // restart so monitoring is re-established instead of + // silently ending. + log.Warnf("Interface monitor: link subscription channel closed unexpectedly for %s", ifaceName) + return true, fmt.Errorf("link subscription channel closed unexpectedly") + } + if restart, err := inspectLinkEvent(update, ifaceName, expectedIndex); restart { + return true, err + } + } + } +} + +// inspectLinkEvent classifies a single netlink link update against the +// tracked WireGuard interface. It returns (true, err) when the engine +// should restart monitoring; (false, nil) means the event is unrelated +// and the caller should keep waiting. +// +// The error component, when non-nil, describes the kernel-side reason +// (deletion or rename); the recreation case returns (true, nil) since +// no error condition is reported. +func inspectLinkEvent(update netlink.LinkUpdate, ifaceName string, expectedIndex int) (bool, error) { + eventIndex := int(update.Index) + eventName := "" + if attrs := update.Attrs(); attrs != nil { + eventName = attrs.Name + } + + switch update.Header.Type { + case syscall.RTM_DELLINK: + return inspectDelLink(eventIndex, ifaceName, expectedIndex) + case syscall.RTM_NEWLINK: + return inspectNewLink(eventIndex, eventName, ifaceName, expectedIndex) + } + return false, nil +} + +// inspectDelLink reports a restart when an RTM_DELLINK arrives for the +// tracked interface index. +func inspectDelLink(eventIndex int, ifaceName string, expectedIndex int) (bool, error) { + if eventIndex != expectedIndex { + return false, nil + } + log.Infof("Interface monitor: %s deleted", ifaceName) + return true, fmt.Errorf("interface %s deleted", ifaceName) +} + +// inspectNewLink reports a restart when an RTM_NEWLINK either: +// +// 1. Introduces a link with our name at a different index (recreation +// after a delete), or +// +// 2. Reports a link still at our index but with a different name +// (in-place rename). The previous polling implementation caught +// this implicitly because net.InterfaceByName(ifaceName) would +// start failing; the event-driven version has to test it. +// +// Same name + same index is just a flag/state change on the existing +// interface and is ignored. +func inspectNewLink(eventIndex int, eventName, ifaceName string, expectedIndex int) (bool, error) { + if eventName == ifaceName && eventIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", + ifaceName, expectedIndex, eventIndex) + return true, nil + } + if eventIndex == expectedIndex && eventName != "" && eventName != ifaceName { + log.Infof("Interface monitor: %s renamed to %s (index %d), restarting engine", + ifaceName, eventName, expectedIndex) + return true, fmt.Errorf("interface %s renamed to %s", ifaceName, eventName) + } + return false, nil +} diff --git a/client/internal/wg_iface_monitor_other.go b/client/internal/wg_iface_monitor_other.go new file mode 100644 index 000000000..afebbf4df --- /dev/null +++ b/client/internal/wg_iface_monitor_other.go @@ -0,0 +1,56 @@ +//go:build !linux + +package internal + +import ( + "context" + "fmt" + "time" + + log "github.com/sirupsen/logrus" +) + +// watchInterface polls net.InterfaceByName at a fixed interval to detect +// deletion or recreation of the WireGuard interface. +// +// This is the fallback used on non-Linux desktop and server platforms +// (darwin, windows, freebsd). It is also compiled on android and ios so +// the package builds on every supported GOOS, but it is never reached +// at runtime there because Start() in wg_iface_monitor.go exits early +// on mobile platforms. +// +// The Linux build (see wg_iface_monitor_linux.go) uses an event-driven +// RTNLGRP_LINK netlink subscription instead, because on Linux +// net.InterfaceByName issues syscall.NetlinkRIB(RTM_GETLINK, ...) which +// dumps the entire kernel link table on every call and produces +// significant allocation churn (netbirdio/netbird#3678). +// +// Windows is also reported in #3678 as affected by RSS climb. A future +// follow-up could implement an event-driven watcher there using +// NotifyIpInterfaceChange from iphlpapi. +func watchInterface(ctx context.Context, ifaceName string, expectedIndex int) (bool, error) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Infof("Interface monitor: stopped for %s", ifaceName) + return false, fmt.Errorf("wg interface monitor stopped: %w", ctx.Err()) + case <-ticker.C: + currentIndex, err := getInterfaceIndex(ifaceName) + if err != nil { + // Interface was deleted + log.Infof("Interface monitor: %s deleted", ifaceName) + return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) + } + + // Check if interface index changed (interface was recreated) + if currentIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", + ifaceName, expectedIndex, currentIndex) + return true, nil + } + } + } +} From 104990dfdd5d9eae1760da5f57ba47a2df7052a6 Mon Sep 17 00:00:00 2001 From: JungwooShin <166088609+typhoon1217@users.noreply.github.com> Date: Tue, 5 May 2026 01:59:29 +0900 Subject: [PATCH 3/6] [client] Display QR code for device auth login URL (#5415) --- client/cmd/login.go | 14 +++++++++++--- client/cmd/qr.go | 25 +++++++++++++++++++++++++ client/cmd/qr_test.go | 26 ++++++++++++++++++++++++++ client/cmd/up.go | 5 +++++ go.mod | 2 ++ go.sum | 4 ++++ 6 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 client/cmd/qr.go create mode 100644 client/cmd/qr_test.go diff --git a/client/cmd/login.go b/client/cmd/login.go index 4521a67c9..bd37e30f1 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -10,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "golang.org/x/term" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" @@ -23,6 +24,7 @@ import ( func init() { loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location") } @@ -256,7 +258,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, } func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.LoginResponse, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager) error { - openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser) + openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser, showQR) resp, err := client.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode, Hostname: hostName}) if err != nil { @@ -324,7 +326,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) } - openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser) + openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser, showQR) tokenInfo, err := oAuthFlow.WaitToken(context.TODO(), flowInfo) if err != nil { @@ -334,7 +336,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro return &tokenInfo, nil } -func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser bool) { +func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser, showQR bool) { var codeMsg string if userCode != "" && !strings.Contains(verificationURIComplete, userCode) { codeMsg = fmt.Sprintf("and enter the code %s to authenticate.", userCode) @@ -348,6 +350,12 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro verificationURIComplete + " " + codeMsg) } + if showQR { + if f, ok := cmd.OutOrStdout().(*os.File); ok && term.IsTerminal(int(f.Fd())) { + printQRCode(f, verificationURIComplete) + } + } + cmd.Println("") if !noBrowser { diff --git a/client/cmd/qr.go b/client/cmd/qr.go new file mode 100644 index 000000000..8b2c489ff --- /dev/null +++ b/client/cmd/qr.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "io" + + "github.com/mdp/qrterminal/v3" +) + +// printQRCode prints a QR code for the given URL to the writer. +// Called only when the user explicitly requests QR output via --qr. +func printQRCode(w io.Writer, url string) { + if url == "" { + return + } + qrterminal.GenerateWithConfig(url, qrterminal.Config{ + Level: qrterminal.M, + Writer: w, + HalfBlocks: true, + BlackChar: qrterminal.BLACK_BLACK, + WhiteChar: qrterminal.WHITE_WHITE, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + QuietZone: qrterminal.QUIET_ZONE, + }) +} diff --git a/client/cmd/qr_test.go b/client/cmd/qr_test.go new file mode 100644 index 000000000..d12705b9e --- /dev/null +++ b/client/cmd/qr_test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "bytes" + "testing" +) + +func TestPrintQRCode_EmptyURL(t *testing.T) { + var buf bytes.Buffer + + printQRCode(&buf, "") + + if buf.Len() != 0 { + t.Error("expected no output for empty URL") + } +} + +func TestPrintQRCode_WritesOutput(t *testing.T) { + var buf bytes.Buffer + + printQRCode(&buf, "https://example.com/auth") + + if buf.Len() == 0 { + t.Error("expected QR code output for non-empty URL") + } +} diff --git a/client/cmd/up.go b/client/cmd/up.go index f5766522a..f4136cb23 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -39,6 +39,9 @@ const ( noBrowserFlag = "no-browser" noBrowserDesc = "do not open the browser for SSO login" + showQRFlag = "qr" + showQRDesc = "show QR code for the SSO login URL (useful for headless machines without browser access)" + profileNameFlag = "profile" profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used." ) @@ -48,6 +51,7 @@ var ( dnsLabels []string dnsLabelsValidated domain.List noBrowser bool + showQR bool profileName string configPath string @@ -80,6 +84,7 @@ func init() { ) upCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + upCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) upCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) upCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) NetBird config file location. ") diff --git a/go.mod b/go.mod index 8e6a481d2..e82e6b10d 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( github.com/libp2p/go-netroute v0.2.1 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 github.com/mdlayher/socket v0.5.1 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/miekg/dns v1.1.59 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 @@ -308,6 +309,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + rsc.io/qr v0.2.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 2abf55142..a71f47d8d 100644 --- a/go.sum +++ b/go.sum @@ -415,6 +415,8 @@ github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0 github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mholt/acmez/v2 v2.0.1 h1:3/3N0u1pLjMK4sNEAFSI+bcvzbPhRpY383sy1kLHJ6k= github.com/mholt/acmez/v2 v2.0.1/go.mod h1:fX4c9r5jYwMyMsC+7tkYRxHibkOTgta5DIFGoe67e1U= github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= @@ -915,3 +917,5 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= From 77a0992dc21644b2b041b7ff45000e232b928158 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 5 May 2026 02:59:41 +0900 Subject: [PATCH 4/6] [misc] Disable govet inline analyzer and tidy go.mod (#6066) --- .golangci.yaml | 5 +++++ go.mod | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.golangci.yaml b/.golangci.yaml index d81ad1377..900af4ac0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -58,6 +58,11 @@ linters: govet: enable: - nilness + disable: + # The inline analyzer flags x/exp/maps Clone/Clear with //go:fix inline + # directives but cannot perform the rewrite due to generic type + # parameter inference limitations in the Go inliner. + - inline enable-all: false revive: rules: diff --git a/go.mod b/go.mod index e82e6b10d..e24312a1a 100644 --- a/go.mod +++ b/go.mod @@ -309,8 +309,8 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect - rsc.io/qr v0.2.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect ) replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 From 97db82492946bce8db6a4ef820ef2d59365f1512 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 4 May 2026 20:43:25 +0200 Subject: [PATCH 5/6] [management] fix proxy reconnect (#6063) --- .../modules/reverseproxy/proxy/manager.go | 6 +- .../reverseproxy/proxy/manager/manager.go | 40 +++++---- .../reverseproxy/proxy/manager_mock.go | 29 +++---- .../modules/reverseproxy/proxy/proxy.go | 3 +- management/internals/shared/grpc/proxy.go | 47 ++++++++--- management/server/store/sql_store.go | 44 ++++++---- management/server/store/store.go | 3 +- management/server/store/store_mock.go | 23 +++++- proxy/management_integration_test.go | 81 +++++++++++++++++-- 9 files changed, 199 insertions(+), 77 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index aa7cd8630..53c52b3aa 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -11,9 +11,9 @@ import ( // Manager defines the interface for proxy operations type Manager interface { - Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *Capabilities) error - Disconnect(ctx context.Context, proxyID string) error - Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, capabilities *Capabilities) (*Proxy, error) + Disconnect(ctx context.Context, proxyID, sessionID string) error + Heartbeat(ctx context.Context, p *Proxy) error GetActiveClusterAddresses(ctx context.Context) ([]string, error) GetActiveClusters(ctx context.Context) ([]Cluster, error) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index d13334e83..341e8c943 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -13,7 +13,8 @@ import ( // store defines the interface for proxy persistence operations type store interface { SaveProxy(ctx context.Context, p *proxy.Proxy) error - UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + DisconnectProxy(ctx context.Context, proxyID, sessionID string) error + UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool @@ -43,7 +44,7 @@ func NewManager(store store, meter metric.Meter) (*Manager, error) { // Connect registers a new proxy connection in the database. // capabilities may be nil for old proxies that do not report them. -func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *proxy.Capabilities) error { +func (m Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, capabilities *proxy.Capabilities) (*proxy.Proxy, error) { now := time.Now() var caps proxy.Capabilities if capabilities != nil { @@ -51,6 +52,7 @@ func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress } p := &proxy.Proxy{ ID: proxyID, + SessionID: sessionID, ClusterAddress: clusterAddress, IPAddress: ipAddress, LastSeen: now, @@ -61,48 +63,42 @@ func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress if err := m.store.SaveProxy(ctx, p); err != nil { log.WithContext(ctx).Errorf("failed to register proxy %s: %v", proxyID, err) - return err + return nil, err } log.WithContext(ctx).WithFields(log.Fields{ "proxyID": proxyID, + "sessionID": sessionID, "clusterAddress": clusterAddress, "ipAddress": ipAddress, }).Info("proxy connected") - return nil + return p, nil } -// Disconnect marks a proxy as disconnected in the database -func (m Manager) Disconnect(ctx context.Context, proxyID string) error { - now := time.Now() - p := &proxy.Proxy{ - ID: proxyID, - Status: "disconnected", - DisconnectedAt: &now, - LastSeen: now, - } - - if err := m.store.SaveProxy(ctx, p); err != nil { - log.WithContext(ctx).Errorf("failed to disconnect proxy %s: %v", proxyID, err) +// Disconnect marks a proxy as disconnected in the database. +func (m Manager) Disconnect(ctx context.Context, proxyID, sessionID string) error { + if err := m.store.DisconnectProxy(ctx, proxyID, sessionID); err != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, err) return err } log.WithContext(ctx).WithFields(log.Fields{ - "proxyID": proxyID, + "proxyID": proxyID, + "sessionID": sessionID, }).Info("proxy disconnected") return nil } -// Heartbeat updates the proxy's last seen timestamp -func (m Manager) Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { - if err := m.store.UpdateProxyHeartbeat(ctx, proxyID, clusterAddress, ipAddress); err != nil { - log.WithContext(ctx).Debugf("failed to update proxy %s heartbeat: %v", proxyID, err) +// Heartbeat updates the proxy's last seen timestamp. +func (m Manager) Heartbeat(ctx context.Context, p *proxy.Proxy) error { + if err := m.store.UpdateProxyHeartbeat(ctx, p); err != nil { + log.WithContext(ctx).Debugf("failed to update proxy %s heartbeat: %v", p.ID, err) return err } - log.WithContext(ctx).Tracef("updated heartbeat for proxy %s", proxyID) + log.WithContext(ctx).Tracef("updated heartbeat for proxy %s session %s", p.ID, p.SessionID) m.metrics.IncrementProxyHeartbeatCount() return nil } diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index 282ca0ba5..98d97b3c6 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -93,31 +93,32 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte } // Connect mocks base method. -func (m *MockManager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *Capabilities) error { +func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, capabilities *Capabilities) (*Proxy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Connect", ctx, proxyID, clusterAddress, ipAddress, capabilities) - ret0, _ := ret[0].(error) - return ret0 + ret := m.ctrl.Call(m, "Connect", ctx, proxyID, sessionID, clusterAddress, ipAddress, capabilities) + ret0, _ := ret[0].(*Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 } // Connect indicates an expected call of Connect. -func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, clusterAddress, ipAddress, capabilities interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, capabilities interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, clusterAddress, ipAddress, capabilities) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, capabilities) } // Disconnect mocks base method. -func (m *MockManager) Disconnect(ctx context.Context, proxyID string) error { +func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Disconnect", ctx, proxyID) + ret := m.ctrl.Call(m, "Disconnect", ctx, proxyID, sessionID) ret0, _ := ret[0].(error) return ret0 } // Disconnect indicates an expected call of Disconnect. -func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID) } // GetActiveClusterAddresses mocks base method. @@ -151,17 +152,17 @@ func (mr *MockManagerMockRecorder) GetActiveClusters(ctx interface{}) *gomock.Ca } // Heartbeat mocks base method. -func (m *MockManager) Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +func (m *MockManager) Heartbeat(ctx context.Context, p *Proxy) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Heartbeat", ctx, proxyID, clusterAddress, ipAddress) + ret := m.ctrl.Call(m, "Heartbeat", ctx, p) ret0, _ := ret[0].(error) return ret0 } // Heartbeat indicates an expected call of Heartbeat. -func (mr *MockManagerMockRecorder) Heartbeat(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, proxyID, clusterAddress, ipAddress) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p) } // MockController is a mock of Controller interface. diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go index 339c82446..dcedb8811 100644 --- a/management/internals/modules/reverseproxy/proxy/proxy.go +++ b/management/internals/modules/reverseproxy/proxy/proxy.go @@ -18,12 +18,13 @@ type Capabilities struct { // Proxy represents a reverse proxy instance type Proxy struct { ID string `gorm:"primaryKey;type:varchar(255)"` + SessionID string `gorm:"type:varchar(36)"` ClusterAddress string `gorm:"type:varchar(255);not null;index:idx_proxy_cluster_status"` IPAddress string `gorm:"type:varchar(45)"` LastSeen time.Time `gorm:"not null;index:idx_proxy_last_seen"` ConnectedAt *time.Time DisconnectedAt *time.Time - Status string `gorm:"type:varchar(20);not null;index:idx_proxy_cluster_status"` + Status string `gorm:"type:varchar(20);not null;index:idx_proxy_cluster_status"` Capabilities Capabilities `gorm:"embedded"` CreatedAt time.Time UpdatedAt time.Time diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index a5e352e75..d811a0f69 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -16,6 +16,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" "google.golang.org/grpc/codes" @@ -89,6 +90,7 @@ const pkceVerifierTTL = 10 * time.Minute // proxyConnection represents a connected proxy type proxyConnection struct { proxyID string + sessionID string address string capabilities *proto.ProxyCapabilities stream proto.ProxyService_GetMappingUpdateServer @@ -166,9 +168,22 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest return status.Errorf(codes.InvalidArgument, "proxy address is invalid") } + sessionID := uuid.NewString() + + if old, loaded := s.connectedProxies.Load(proxyID); loaded { + oldConn := old.(*proxyConnection) + log.WithFields(log.Fields{ + "proxy_id": proxyID, + "old_session_id": oldConn.sessionID, + "new_session_id": sessionID, + }).Info("Superseding existing proxy connection") + oldConn.cancel() + } + connCtx, cancel := context.WithCancel(ctx) conn := &proxyConnection{ proxyID: proxyID, + sessionID: sessionID, address: proxyAddress, capabilities: req.GetCapabilities(), stream: stream, @@ -188,12 +203,13 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest caps = &proxy.Capabilities{ SupportsCustomPorts: c.SupportsCustomPorts, RequireSubdomain: c.RequireSubdomain, - SupportsCrowdsec: c.SupportsCrowdsec, + SupportsCrowdsec: c.SupportsCrowdsec, } } - if err := s.proxyManager.Connect(ctx, proxyID, proxyAddress, peerInfo, caps); err != nil { + proxyRecord, err := s.proxyManager.Connect(ctx, proxyID, sessionID, proxyAddress, peerInfo, caps) + if err != nil { log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", proxyID, err) - s.connectedProxies.Delete(proxyID) + s.connectedProxies.CompareAndDelete(proxyID, conn) if unregErr := s.proxyController.UnregisterProxyFromCluster(ctx, conn.address, proxyID); unregErr != nil { log.WithContext(ctx).Debugf("cleanup after Connect failure for proxy %s: %v", proxyID, unregErr) } @@ -202,22 +218,27 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest log.WithFields(log.Fields{ "proxy_id": proxyID, + "session_id": sessionID, "address": proxyAddress, "cluster_addr": proxyAddress, "total_proxies": len(s.GetConnectedProxies()), }).Info("Proxy registered in cluster") defer func() { - if err := s.proxyManager.Disconnect(context.Background(), proxyID); err != nil { - log.Warnf("Failed to mark proxy %s as disconnected: %v", proxyID, err) + if !s.connectedProxies.CompareAndDelete(proxyID, conn) { + log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", proxyID, sessionID) + cancel() + return } - s.connectedProxies.Delete(proxyID) if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); err != nil { log.Warnf("Failed to unregister proxy %s from cluster: %v", proxyID, err) } + if err := s.proxyManager.Disconnect(context.Background(), proxyID, sessionID); err != nil { + log.Warnf("Failed to mark proxy %s as disconnected: %v", proxyID, err) + } cancel() - log.Infof("Proxy %s disconnected", proxyID) + log.Infof("Proxy %s session %s disconnected", proxyID, sessionID) }() if err := s.sendSnapshot(ctx, conn); err != nil { @@ -227,29 +248,31 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest errChan := make(chan error, 2) go s.sender(conn, errChan) - // Start heartbeat goroutine - go s.heartbeat(connCtx, proxyID, proxyAddress, peerInfo) + go s.heartbeat(connCtx, proxyRecord) select { case err := <-errChan: + log.WithContext(ctx).Warnf("Failed to send update: %v", err) return fmt.Errorf("send update to proxy %s: %w", proxyID, err) case <-connCtx.Done(): + log.WithContext(ctx).Infof("Proxy %s context canceled", proxyID) return connCtx.Err() } } // heartbeat updates the proxy's last_seen timestamp every minute -func (s *ProxyServiceServer) heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) { +func (s *ProxyServiceServer) heartbeat(ctx context.Context, p *proxy.Proxy) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - if err := s.proxyManager.Heartbeat(ctx, proxyID, clusterAddress, ipAddress); err != nil { - log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", proxyID, err) + if err := s.proxyManager.Heartbeat(ctx, p); err != nil { + log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", p.ID, err) } case <-ctx.Done(): + log.WithContext(ctx).Infof("proxy %s heartbeat stopped: context canceled", p.ID) return } } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 0a716d08d..1fa3d08ee 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5437,13 +5437,35 @@ func (s *SqlStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { return nil } -// UpdateProxyHeartbeat updates the last_seen timestamp for a proxy or creates a new entry if it doesn't exist -func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +// DisconnectProxy marks a proxy as disconnected only if the session ID matches. +// This prevents a slow-to-close old session from overwriting a newer reconnection. +func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + now := time.Now() + result := s.db. + Model(&proxy.Proxy{}). + Where("id = ? AND session_id = ?", proxyID, sessionID). + Updates(map[string]any{ + "status": "disconnected", + "disconnected_at": now, + "last_seen": now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, result.Error) + return status.Errorf(status.Internal, "failed to disconnect proxy") + } + if result.RowsAffected == 0 { + log.WithContext(ctx).Debugf("proxy %s session %s: no row updated (superseded by newer session)", proxyID, sessionID) + } + return nil +} + +// UpdateProxyHeartbeat updates the last_seen timestamp for the proxy's current session. +func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { now := time.Now() result := s.db. Model(&proxy.Proxy{}). - Where("id = ? AND status = ?", proxyID, "connected"). + Where("id = ? AND session_id = ?", p.ID, p.SessionID). Update("last_seen", now) if result.Error != nil { @@ -5452,17 +5474,11 @@ func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAdd } if result.RowsAffected == 0 { - p := &proxy.Proxy{ - ID: proxyID, - ClusterAddress: clusterAddress, - IPAddress: ipAddress, - LastSeen: now, - ConnectedAt: &now, - Status: "connected", - } - if err := s.db.Save(p).Error; err != nil { - log.WithContext(ctx).Errorf("failed to create proxy on heartbeat: %v", err) - return status.Errorf(status.Internal, "failed to create proxy on heartbeat") + p.LastSeen = now + p.ConnectedAt = &now + p.Status = "connected" + if err := s.db.Create(p).Error; err != nil { + log.WithContext(ctx).Debugf("proxy %s session %s: heartbeat fallback insert skipped: %v", p.ID, p.SessionID, err) } } diff --git a/management/server/store/store.go b/management/server/store/store.go index 0d8b0678a..447c85547 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -284,7 +284,8 @@ type Store interface { DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error SaveProxy(ctx context.Context, proxy *proxy.Proxy) error - UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + DisconnectProxy(ctx context.Context, proxyID, sessionID string) error + UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index beee13d96..d8bd826a8 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -178,6 +178,7 @@ func (mr *MockStoreMockRecorder) GetClusterSupportsCrowdSec(ctx, clusterAddr int mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCrowdSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCrowdSec), ctx, clusterAddr) } + // Close mocks base method. func (m *MockStore) Close(ctx context.Context) error { m.ctrl.T.Helper() @@ -2799,6 +2800,20 @@ func (mr *MockStoreMockRecorder) SaveProxy(ctx, proxy interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxy", reflect.TypeOf((*MockStore)(nil).SaveProxy), ctx, proxy) } +// DisconnectProxy mocks base method. +func (m *MockStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisconnectProxy", ctx, proxyID, sessionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DisconnectProxy indicates an expected call of DisconnectProxy. +func (mr *MockStoreMockRecorder) DisconnectProxy(ctx, proxyID, sessionID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectProxy", reflect.TypeOf((*MockStore)(nil).DisconnectProxy), ctx, proxyID, sessionID) +} + // SaveProxyAccessToken mocks base method. func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types2.ProxyAccessToken) error { m.ctrl.T.Helper() @@ -2995,17 +3010,17 @@ func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{} } // UpdateProxyHeartbeat mocks base method. -func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, proxyID, clusterAddress, ipAddress) + ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, p) ret0, _ := ret[0].(error) return ret0 } // UpdateProxyHeartbeat indicates an expected call of UpdateProxyHeartbeat. -func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, p interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, proxyID, clusterAddress, ipAddress) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, p) } // UpdateService mocks base method. diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index 4b1ecf922..e9eae3210 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -201,15 +201,15 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, // testProxyManager is a mock implementation of proxy.Manager for testing. type testProxyManager struct{} -func (m *testProxyManager) Connect(_ context.Context, _, _, _ string, _ *nbproxy.Capabilities) error { +func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _ string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { + return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: "connected"}, nil +} + +func (m *testProxyManager) Disconnect(_ context.Context, _, _ string) error { return nil } -func (m *testProxyManager) Disconnect(_ context.Context, _ string) error { - return nil -} - -func (m *testProxyManager) Heartbeat(_ context.Context, _, _, _ string) error { +func (m *testProxyManager) Heartbeat(_ context.Context, _ *nbproxy.Proxy) error { return nil } @@ -656,3 +656,72 @@ func TestIntegration_ProxyConnection_MultipleProxiesReceiveUpdates(t *testing.T) assert.Equal(t, 2, count, "Proxy %s should receive 2 mappings", proxyID) } } + +// TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState verifies that +// when a proxy reconnects before the old stream's cleanup runs, the new +// connection is NOT removed by the stale defer. +func TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + clusterAddress := "test.proxy.io" + proxyID := "test-proxy-race" + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx1, cancel1 := context.WithCancel(context.Background()) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + for i := 0; i < 2; i++ { + _, err := stream1.Recv() + require.NoError(t, err) + } + + require.Contains(t, setup.proxyService.GetConnectedProxies(), proxyID, + "proxy should be registered after first connection") + + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + for i := 0; i < 2; i++ { + _, err := stream2.Recv() + require.NoError(t, err) + } + + cancel1() + + time.Sleep(200 * time.Millisecond) + + assert.Contains(t, setup.proxyService.GetConnectedProxies(), proxyID, + "proxy should still be registered after old connection cleanup — old defer must not remove new connection") + + setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: "rp-1", + AccountId: "test-account-1", + Domain: "app1.test.proxy.io", + }}, + }) + + msg, err := stream2.Recv() + require.NoError(t, err, "new stream should still receive updates") + require.NotEmpty(t, msg.GetMapping(), "update should contain the mapping") + assert.Equal(t, "rp-1", msg.GetMapping()[0].GetId()) +} From cd8e71002fe6c8bf1197ca30abe5d2f5e813adc1 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 5 May 2026 22:26:27 +0900 Subject: [PATCH 6/6] [client] Bump go-netroute to v0.4.0 and drop fork (#6062) --- client/internal/portforward/pcp/nat.go | 15 ++++++- .../systemops/systemops_generic.go | 16 ++++++++ .../systemops/systemops_generic_test.go | 6 ++- .../systemops/v6route_bsd_test.go | 30 ++++++++++++++ .../systemops/v6route_linux_test.go | 41 +++++++++++++++++++ .../systemops/v6route_windows_test.go | 34 +++++++++++++++ go.mod | 18 ++++---- go.sum | 32 +++++++-------- 8 files changed, 163 insertions(+), 29 deletions(-) create mode 100644 client/internal/routemanager/systemops/v6route_bsd_test.go create mode 100644 client/internal/routemanager/systemops/v6route_linux_test.go create mode 100644 client/internal/routemanager/systemops/v6route_windows_test.go diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go index 1dc24274b..6491e7367 100644 --- a/client/internal/portforward/pcp/nat.go +++ b/client/internal/portforward/pcp/nat.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "runtime" "sync" "time" @@ -177,7 +178,12 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { return nil, nil, err } - _, gateway, localIP, err = router.Route(net.IPv4zero) + dst := net.IPv4zero + if runtime.GOOS == "linux" { + // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux. + dst = net.IPv4(0, 0, 0, 1) + } + _, gateway, localIP, err = router.Route(dst) if err != nil { return nil, nil, err } @@ -196,7 +202,12 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { return nil, nil, err } - _, gateway, localIP, err = router.Route(net.IPv6zero) + dst := net.IPv6zero + if runtime.GOOS == "linux" { + // ::2 + dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} + } + _, gateway, localIP, err = router.Route(dst) if err != nil { return nil, nil, err } diff --git a/client/internal/routemanager/systemops/systemops_generic.go b/client/internal/routemanager/systemops/systemops_generic.go index 4211eb057..bf7b95a28 100644 --- a/client/internal/routemanager/systemops/systemops_generic.go +++ b/client/internal/routemanager/systemops/systemops_generic.go @@ -342,6 +342,22 @@ func GetNextHop(ip netip.Addr) (Nexthop, error) { if err != nil { return Nexthop{}, fmt.Errorf("new netroute: %w", err) } + + // go-netroute v0.4.0 rejects unspecified destinations on Linux with a hard + // client-side check. Substitute the lowest non-loopback address so the + // lookup falls through to the default route (::1 / 127.0.0.1 would match + // loopback, ::/0.0.0.0 are unspec). BSD/Windows pass the query straight to + // the kernel and need no substitution. + if runtime.GOOS == "linux" && ip.IsUnspecified() { + if ip.Is6() { + // ::2 + ip = netip.AddrFrom16([16]byte{15: 2}) + } else { + // 0.0.0.1 + ip = netip.AddrFrom4([4]byte{0, 0, 0, 1}) + } + } + intf, gateway, preferredSrc, err := r.Route(ip.AsSlice()) if err != nil { log.Debugf("Failed to get route for %s: %v", ip, err) diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index 01916fbe3..08e354a78 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -354,9 +354,13 @@ func TestAddRouteToNonVPNIntf(t *testing.T) { require.NoError(t, err, "Should be able to get IPv4 default route") t.Logf("Initial IPv4 next hop: %s", initialNextHopV4) + if testCase.prefix.Addr().Is6() && !testCase.expectError { + ensureIPv6DefaultRoute(t) + } + initialNextHopV6, err := GetNextHop(netip.IPv6Unspecified()) if testCase.prefix.Addr().Is6() && - (errors.Is(err, vars.ErrRouteNotFound) || initialNextHopV6.Intf != nil && strings.HasPrefix(initialNextHopV6.Intf.Name, "utun")) { + initialNextHopV6.Intf != nil && strings.HasPrefix(initialNextHopV6.Intf.Name, "utun") { t.Skip("Skipping test as no ipv6 default route is available") } if err != nil && !errors.Is(err, vars.ErrRouteNotFound) { diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go new file mode 100644 index 000000000..98ce29c6d --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_bsd_test.go @@ -0,0 +1,30 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package systemops + +import ( + "bytes" + "os/exec" + "testing" +) + +// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback +// interface so route lookups for global IPv6 prefixes resolve in environments +// without v6 connectivity. If a default already exists it is left alone. +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + out, err := exec.Command("route", "-6", "add", "default", "-iface", "lo0").CombinedOutput() + if err != nil { + // Existing default; nothing to install or clean up. + if bytes.Contains(out, []byte("route already in table")) { + return + } + t.Skipf("install IPv6 fallback default route: %v: %s", err, out) + } + t.Cleanup(func() { + if out, err := exec.Command("route", "-6", "delete", "default").CombinedOutput(); err != nil { + t.Logf("delete IPv6 fallback default route: %v: %s", err, out) + } + }) +} diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go new file mode 100644 index 000000000..0b17cefff --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -0,0 +1,41 @@ +//go:build linux && !android + +package systemops + +import ( + "errors" + "net" + "syscall" + "testing" + + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" +) + +// ensureIPv6DefaultRoute installs a low-preference IPv6 default route via the +// loopback interface so route lookups for global IPv6 prefixes resolve in +// environments without v6 connectivity. Any pre-existing default route wins +// because of its lower metric. +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + lo, err := netlink.LinkByName("lo") + require.NoError(t, err, "find loopback interface") + + route := &netlink.Route{ + Dst: &net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}, + LinkIndex: lo.Attrs().Index, + Priority: 1 << 20, + } + if err := netlink.RouteAdd(route); err != nil { + if errors.Is(err, syscall.EEXIST) { + return + } + t.Skipf("install IPv6 fallback default route: %v", err) + } + t.Cleanup(func() { + if err := netlink.RouteDel(route); err != nil && !errors.Is(err, syscall.ESRCH) { + t.Logf("delete IPv6 fallback default route: %v", err) + } + }) +} diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go new file mode 100644 index 000000000..f79277b87 --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_windows_test.go @@ -0,0 +1,34 @@ +//go:build windows + +package systemops + +import ( + "bytes" + "os/exec" + "testing" +) + +const loopbackIfaceWindows = "Loopback Pseudo-Interface 1" + +// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback +// interface so route lookups for global IPv6 prefixes resolve in environments +// without v6 connectivity. If a default already exists it is left alone. +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + script := `New-NetRoute -DestinationPrefix "::/0" -InterfaceAlias "` + loopbackIfaceWindows + `" -RouteMetric 9999 -PolicyStore ActiveStore -ErrorAction Stop` + out, err := exec.Command("powershell", "-Command", script).CombinedOutput() + if err != nil { + // Existing default; nothing to install or clean up. + if bytes.Contains(out, []byte("already exists")) { + return + } + t.Skipf("install IPv6 fallback default route: %v: %s", err, out) + } + t.Cleanup(func() { + script := `Remove-NetRoute -DestinationPrefix "::/0" -InterfaceAlias "` + loopbackIfaceWindows + `" -Confirm:$false -ErrorAction Stop` + if out, err := exec.Command("powershell", "-Command", script).CombinedOutput(); err != nil { + t.Logf("delete IPv6 fallback default route: %v: %s", err, out) + } + }) +} diff --git a/go.mod b/go.mod index e24312a1a..bc4e8af15 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,8 @@ require ( github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.9 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.49.0 - golang.org/x/sys v0.42.0 + golang.org/x/crypto v0.50.0 + golang.org/x/sys v0.43.0 golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -68,7 +68,7 @@ require ( github.com/jackc/pgx/v5 v5.5.5 github.com/libdns/route53 v1.5.0 github.com/libp2p/go-nat v0.2.0 - github.com/libp2p/go-netroute v0.2.1 + github.com/libp2p/go-netroute v0.4.0 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 github.com/mdlayher/socket v0.5.1 github.com/mdp/qrterminal/v3 v3.2.1 @@ -118,11 +118,11 @@ require ( goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.33.0 - golang.org/x/net v0.52.0 + golang.org/x/mod v0.34.0 + golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 gopkg.in/yaml.v3 v3.0.1 @@ -303,8 +303,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/image v0.33.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect @@ -323,8 +323,6 @@ replace github.com/cloudflare/circl => github.com/cunicu/circl v0.0.0-2023080111 replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 -replace github.com/libp2p/go-netroute => github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 - replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.0 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 diff --git a/go.sum b/go.sum index a71f47d8d..d54dc01e6 100644 --- a/go.sum +++ b/go.sum @@ -395,6 +395,8 @@ github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA= github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q= github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= +github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= +github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= @@ -453,8 +455,6 @@ github.com/netbirdio/dex v0.244.0 h1:1GOvi8wnXYassnKGildzNqRHq0RbcfEUw7LKYpKIN7U github.com/netbirdio/dex v0.244.0/go.mod h1:STGInJhPcAflrHmDO7vyit2kSq03PdL+8zQPoGALtcU= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 h1:TDtJKmM6Sf8uYFx/dMeqNOL90KUoRscdfpFZ3Im89uk= -github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944/go.mod h1:sHA6TRxjQ6RLbnI+3R4DZo2Eseg/iKiPRfNmcuNySVQ= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= @@ -711,8 +711,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= @@ -729,8 +729,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -749,8 +749,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -801,8 +801,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -815,8 +815,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -828,8 +828,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -843,8 +843,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=