From 9f0aa1ce26c09883a507cda469f605df3d562674 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 16 May 2026 09:19:34 +0200 Subject: [PATCH 001/151] Add embedded VNC server with JWT auth and per-peer toggle --- .github/workflows/wasm-build-validation.yml | 4 +- client/cmd/up.go | 9 + client/cmd/vnc_agent.go | 67 ++ client/cmd/vnc_flags.go | 9 + client/internal/auth/auth.go | 1 + client/internal/connect.go | 2 + client/internal/debug/debug.go | 3 + client/internal/debug/debug_test.go | 1 + client/internal/engine.go | 18 + client/internal/engine_vnc.go | 238 +++++ client/internal/engine_vnc_console_freebsd.go | 31 + client/internal/engine_vnc_console_linux.go | 28 + client/internal/engine_vnc_darwin.go | 23 + client/internal/engine_vnc_stub.go | 13 + client/internal/engine_vnc_windows.go | 13 + client/internal/engine_vnc_x11.go | 35 + client/internal/profilemanager/config.go | 17 + client/internal/statemanager/manager.go | 8 + client/proto/daemon.pb.go | 953 +++++++++-------- client/proto/daemon.proto | 12 + client/server/server.go | 23 + client/server/setconfig_test.go | 6 + client/ssh/server/executor_windows.go | 4 +- client/ssh/server/server.go | 44 +- client/status/status.go | 16 + client/status/status_test.go | 7 + client/system/info.go | 5 + client/ui/client_ui.go | 11 + client/ui/const.go | 1 + client/ui/event_handler.go | 11 + client/vnc/server/agent_windows.go | 816 +++++++++++++++ client/vnc/server/capture_darwin.go | 597 +++++++++++ client/vnc/server/capture_dxgi_windows.go | 99 ++ client/vnc/server/capture_fb_freebsd.go | 148 +++ client/vnc/server/capture_fb_linux.go | 230 +++++ client/vnc/server/capture_fb_unix.go | 150 +++ client/vnc/server/capture_windows.go | 544 ++++++++++ client/vnc/server/capture_x11.go | 479 +++++++++ client/vnc/server/capture_x11_shm_linux.go | 96 ++ client/vnc/server/capture_x11_shm_stub.go | 24 + client/vnc/server/coalesce_test.go | 75 ++ client/vnc/server/hextile_test.go | 188 ++++ client/vnc/server/input_darwin.go | 613 +++++++++++ client/vnc/server/input_uinput_unix.go | 500 +++++++++ client/vnc/server/input_windows.go | 500 +++++++++ client/vnc/server/input_x11.go | 283 ++++++ client/vnc/server/keysym_typetext.go | 71 ++ client/vnc/server/rfb.go | 905 +++++++++++++++++ client/vnc/server/rfb_bench_test.go | 405 ++++++++ client/vnc/server/server.go | 754 ++++++++++++++ client/vnc/server/server_darwin.go | 21 + client/vnc/server/server_stub.go | 21 + client/vnc/server/server_test.go | 412 ++++++++ client/vnc/server/server_windows.go | 312 ++++++ client/vnc/server/server_x11.go | 21 + client/vnc/server/session.go | 672 ++++++++++++ client/vnc/server/shutdown_state.go | 80 ++ client/vnc/server/stubs.go | 46 + client/vnc/server/swizzle.go | 29 + client/vnc/server/tight_test.go | 84 ++ client/vnc/server/virtual_x11.go | 725 +++++++++++++ client/wasm/cmd/main.go | 140 ++- client/wasm/internal/vnc/proxy.go | 427 ++++++++ go.mod | 2 + go.sum | 4 + .../internals/shared/grpc/conversion.go | 26 +- management/internals/shared/grpc/server.go | 1 + .../http/handlers/peers/peers_handler.go | 4 +- management/server/management_proto_test.go | 1 + management/server/peer/peer.go | 3 + management/server/policy_test.go | 32 +- management/server/types/account.go | 157 ++- management/server/types/network.go | 1 + .../server/types/networkmap_components.go | 153 ++- management/server/types/policy.go | 4 + .../server/types/policy_authorized_users.go | 160 +++ shared/auth/jwt/token_age.go | 68 ++ shared/management/client/grpc.go | 3 + shared/management/http/api/openapi.yml | 4 + shared/management/http/api/types.gen.go | 23 +- shared/management/proto/management.pb.go | 959 ++++++++++-------- shared/management/proto/management.proto | 19 + shared/management/proto/management_grpc.pb.go | 224 ++-- 83 files changed, 12688 insertions(+), 1240 deletions(-) create mode 100644 client/cmd/vnc_agent.go create mode 100644 client/cmd/vnc_flags.go create mode 100644 client/internal/engine_vnc.go create mode 100644 client/internal/engine_vnc_console_freebsd.go create mode 100644 client/internal/engine_vnc_console_linux.go create mode 100644 client/internal/engine_vnc_darwin.go create mode 100644 client/internal/engine_vnc_stub.go create mode 100644 client/internal/engine_vnc_windows.go create mode 100644 client/internal/engine_vnc_x11.go create mode 100644 client/vnc/server/agent_windows.go create mode 100644 client/vnc/server/capture_darwin.go create mode 100644 client/vnc/server/capture_dxgi_windows.go create mode 100644 client/vnc/server/capture_fb_freebsd.go create mode 100644 client/vnc/server/capture_fb_linux.go create mode 100644 client/vnc/server/capture_fb_unix.go create mode 100644 client/vnc/server/capture_windows.go create mode 100644 client/vnc/server/capture_x11.go create mode 100644 client/vnc/server/capture_x11_shm_linux.go create mode 100644 client/vnc/server/capture_x11_shm_stub.go create mode 100644 client/vnc/server/coalesce_test.go create mode 100644 client/vnc/server/hextile_test.go create mode 100644 client/vnc/server/input_darwin.go create mode 100644 client/vnc/server/input_uinput_unix.go create mode 100644 client/vnc/server/input_windows.go create mode 100644 client/vnc/server/input_x11.go create mode 100644 client/vnc/server/keysym_typetext.go create mode 100644 client/vnc/server/rfb.go create mode 100644 client/vnc/server/rfb_bench_test.go create mode 100644 client/vnc/server/server.go create mode 100644 client/vnc/server/server_darwin.go create mode 100644 client/vnc/server/server_stub.go create mode 100644 client/vnc/server/server_test.go create mode 100644 client/vnc/server/server_windows.go create mode 100644 client/vnc/server/server_x11.go create mode 100644 client/vnc/server/session.go create mode 100644 client/vnc/server/shutdown_state.go create mode 100644 client/vnc/server/stubs.go create mode 100644 client/vnc/server/swizzle.go create mode 100644 client/vnc/server/tight_test.go create mode 100644 client/vnc/server/virtual_x11.go create mode 100644 client/wasm/internal/vnc/proxy.go create mode 100644 management/server/types/policy_authorized_users.go create mode 100644 shared/auth/jwt/token_age.go diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 81ae36e785e..6644840fed0 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -61,8 +61,8 @@ jobs: echo "Size: ${SIZE} bytes (${SIZE_MB} MB)" - if [ ${SIZE} -gt 58720256 ]; then - echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!" + if [ ${SIZE} -gt 62914560 ]; then + echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!" exit 1 fi diff --git a/client/cmd/up.go b/client/cmd/up.go index cabd0aacff1..167b418fbe5 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -361,6 +361,9 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro if cmd.Flag(serverSSHAllowedFlag).Changed { req.ServerSSHAllowed = &serverSSHAllowed } + if cmd.Flag(serverVNCAllowedFlag).Changed { + req.ServerVNCAllowed = &serverVNCAllowed + } if cmd.Flag(enableSSHRootFlag).Changed { req.EnableSSHRoot = &enableSSHRoot } @@ -467,6 +470,9 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil if cmd.Flag(serverSSHAllowedFlag).Changed { ic.ServerSSHAllowed = &serverSSHAllowed } + if cmd.Flag(serverVNCAllowedFlag).Changed { + ic.ServerVNCAllowed = &serverVNCAllowed + } if cmd.Flag(enableSSHRootFlag).Changed { ic.EnableSSHRoot = &enableSSHRoot @@ -595,6 +601,9 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte if cmd.Flag(serverSSHAllowedFlag).Changed { loginRequest.ServerSSHAllowed = &serverSSHAllowed } + if cmd.Flag(serverVNCAllowedFlag).Changed { + loginRequest.ServerVNCAllowed = &serverVNCAllowed + } if cmd.Flag(enableSSHRootFlag).Changed { loginRequest.EnableSSHRoot = &enableSSHRoot diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go new file mode 100644 index 00000000000..5fe6c97a030 --- /dev/null +++ b/client/cmd/vnc_agent.go @@ -0,0 +1,67 @@ +//go:build windows + +package cmd + +import ( + "fmt" + "net/netip" + "os" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + vncserver "github.com/netbirdio/netbird/client/vnc/server" +) + +var vncAgentPort string + +func init() { + vncAgentCmd.Flags().StringVar(&vncAgentPort, "port", "15900", "Port for the VNC agent to listen on") + rootCmd.AddCommand(vncAgentCmd) +} + +// vncAgentCmd runs a VNC server in the current user session, listening on +// localhost. It is spawned by the NetBird service (Session 0) via +// CreateProcessAsUser into the interactive console session. +var vncAgentCmd = &cobra.Command{ + Use: "vnc-agent", + Short: "Run VNC capture agent (internal, spawned by service)", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Agent's stderr is piped to the service which relogs it. + // Use JSON format with caller info for structured parsing. + log.SetReportCaller(true) + log.SetFormatter(&log.JSONFormatter{}) + log.SetOutput(os.Stderr) + + sessionID := vncserver.GetCurrentSessionID() + log.Infof("VNC agent starting on 127.0.0.1:%s (session %d)", vncAgentPort, sessionID) + + token := os.Getenv("NB_VNC_AGENT_TOKEN") + if token == "" { + return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service") + } + + capturer := vncserver.NewDesktopCapturer() + injector := vncserver.NewWindowsInputInjector() + srv := vncserver.New(capturer, injector, "") + srv.SetDisableAuth(true) + srv.SetAgentToken(token) + + port, err := netip.ParseAddrPort("127.0.0.1:" + vncAgentPort) + if err != nil { + return fmt.Errorf("parse listen addr: %w", err) + } + + loopback := netip.PrefixFrom(netip.AddrFrom4([4]byte{127, 0, 0, 0}), 8) + if err := srv.Start(cmd.Context(), port, loopback); err != nil { + return fmt.Errorf("start vnc server: %w", err) + } + log.Infof("vnc-agent listening on 127.0.0.1:%s, ready", vncAgentPort) + + <-cmd.Context().Done() + log.Info("vnc-agent context cancelled, shutting down") + return srv.Stop() + }, + SilenceUsage: true, +} diff --git a/client/cmd/vnc_flags.go b/client/cmd/vnc_flags.go new file mode 100644 index 00000000000..cfcbaeab1f5 --- /dev/null +++ b/client/cmd/vnc_flags.go @@ -0,0 +1,9 @@ +package cmd + +const serverVNCAllowedFlag = "allow-server-vnc" + +var serverVNCAllowed bool + +func init() { + upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer") +} diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index afc8ee77f4a..5d98fd644f0 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -315,6 +315,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.RosenpassEnabled, a.config.RosenpassPermissive, a.config.ServerSSHAllowed, + a.config.ServerVNCAllowed, a.config.DisableClientRoutes, a.config.DisableServerRoutes, a.config.DisableDNS, diff --git a/client/internal/connect.go b/client/internal/connect.go index ea884818fe6..af1b6a9cd7f 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -562,6 +562,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf RosenpassEnabled: config.RosenpassEnabled, RosenpassPermissive: config.RosenpassPermissive, ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed), + ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed, EnableSSHRoot: config.EnableSSHRoot, EnableSSHSFTP: config.EnableSSHSFTP, EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding, @@ -644,6 +645,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.RosenpassEnabled, config.RosenpassPermissive, config.ServerSSHAllowed, + config.ServerVNCAllowed, config.DisableClientRoutes, config.DisableServerRoutes, config.DisableDNS, diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index ebaf71b2188..09ac2c2cf90 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -636,6 +636,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) if g.internalConfig.SSHJWTCacheTTL != nil { configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL)) } + if g.internalConfig.ServerVNCAllowed != nil { + configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed)) + } configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes)) configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes)) diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 39b9722444f..5830583a332 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -862,6 +862,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { RosenpassEnabled: true, RosenpassPermissive: true, ServerSSHAllowed: &bTrue, + ServerVNCAllowed: &bTrue, EnableSSHRoot: &bTrue, EnableSSHSFTP: &bTrue, EnableSSHLocalPortForwarding: &bTrue, diff --git a/client/internal/engine.go b/client/internal/engine.go index 3bd0d462125..9d89ee063a5 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -123,6 +123,7 @@ type EngineConfig struct { RosenpassPermissive bool ServerSSHAllowed bool + ServerVNCAllowed bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -205,6 +206,7 @@ type Engine struct { networkMonitor *networkmonitor.NetworkMonitor sshServer sshServer + vncSrv vncServer statusRecorder *peer.Status @@ -320,6 +322,10 @@ func (e *Engine) Stop() error { log.Warnf("failed to stop SSH server: %v", err) } + if err := e.stopVNCServer(); err != nil { + log.Warnf("failed to stop VNC server: %v", err) + } + e.cleanupSSHConfig() if e.ingressGatewayMgr != nil { @@ -1010,6 +1016,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { e.config.RosenpassEnabled, e.config.RosenpassPermissive, &e.config.ServerSSHAllowed, + &e.config.ServerVNCAllowed, e.config.DisableClientRoutes, e.config.DisableServerRoutes, e.config.DisableDNS, @@ -1057,6 +1064,10 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { } } + if err := e.updateVNC(conf.GetSshConfig()); err != nil { + log.Warnf("failed handling VNC server setup: %v", err) + } + state := e.statusRecorder.GetLocalPeerState() state.IP = e.wgInterface.Address().String() state.IPv6 = e.wgInterface.Address().IPv6String() @@ -1182,6 +1193,7 @@ func (e *Engine) receiveManagementEvents() { e.config.RosenpassEnabled, e.config.RosenpassPermissive, &e.config.ServerSSHAllowed, + &e.config.ServerVNCAllowed, e.config.DisableClientRoutes, e.config.DisableServerRoutes, e.config.DisableDNS, @@ -1371,6 +1383,11 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.updateSSHServerAuth(networkMap.GetSshAuth()) } + // VNC auth: always sync, including nil so cleared auth on the management + // side is applied locally, and so it isn't skipped on the RemotePeersIsEmpty + // cleanup path. + e.updateVNCServerAuth(networkMap.GetVncAuth()) + // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) @@ -1826,6 +1843,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err e.config.RosenpassEnabled, e.config.RosenpassPermissive, &e.config.ServerSSHAllowed, + &e.config.ServerVNCAllowed, e.config.DisableClientRoutes, e.config.DisableServerRoutes, e.config.DisableDNS, diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go new file mode 100644 index 00000000000..6341a5cdd06 --- /dev/null +++ b/client/internal/engine_vnc.go @@ -0,0 +1,238 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "net/netip" + + log "github.com/sirupsen/logrus" + + firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + vncserver "github.com/netbirdio/netbird/client/vnc/server" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +const ( + vncExternalPort uint16 = 5900 + vncInternalPort uint16 = 25900 +) + +type vncServer interface { + Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error + Stop() error +} + +func (e *Engine) setupVNCPortRedirection() error { + if e.firewall == nil || e.wgInterface == nil { + return nil + } + + localAddr := e.wgInterface.Address().IP + if !localAddr.IsValid() { + return errors.New("invalid local NetBird address") + } + + if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil { + return fmt.Errorf("add VNC port redirection: %w", err) + } + log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vncExternalPort, localAddr, vncInternalPort) + + return nil +} + +func (e *Engine) cleanupVNCPortRedirection() error { + if e.firewall == nil || e.wgInterface == nil { + return nil + } + + localAddr := e.wgInterface.Address().IP + if !localAddr.IsValid() { + return errors.New("invalid local NetBird address") + } + + if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil { + return fmt.Errorf("remove VNC port redirection: %w", err) + } + + return nil +} + +// updateVNC handles starting/stopping the VNC server based on the config flag. +// sshConf provides the JWT identity provider config (shared with SSH). +func (e *Engine) updateVNC(sshConf *mgmProto.SSHConfig) error { + if !e.config.ServerVNCAllowed { + if e.vncSrv != nil { + log.Info("VNC server disabled, stopping") + } + return e.stopVNCServer() + } + + if e.config.BlockInbound { + log.Info("VNC server disabled because inbound connections are blocked") + return e.stopVNCServer() + } + + if e.vncSrv != nil { + // Update JWT config on existing server in case management sent new config. + e.updateVNCServerJWT(sshConf) + return nil + } + + return e.startVNCServer(sshConf) +} + +func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { + if e.wgInterface == nil { + return errors.New("wg interface not initialized") + } + + capturer, injector, ok := newPlatformVNC() + if !ok { + log.Debug("VNC server not supported on this platform") + return nil + } + + netbirdIP := e.wgInterface.Address().IP + + srv := vncserver.New(capturer, injector, "") + if vncNeedsServiceMode() { + log.Info("VNC: running in Session 0, enabling service mode (agent proxy)") + srv.SetServiceMode(true) + } + + if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil { + audiences := protoJWT.GetAudiences() + if len(audiences) == 0 && protoJWT.GetAudience() != "" { + audiences = []string{protoJWT.GetAudience()} + } + srv.SetJWTConfig(&vncserver.JWTConfig{ + Issuer: protoJWT.GetIssuer(), + Audiences: audiences, + KeysLocation: protoJWT.GetKeysLocation(), + MaxTokenAge: protoJWT.GetMaxTokenAge(), + }) + log.Debugf("VNC: JWT authentication configured (issuer=%s)", protoJWT.GetIssuer()) + } + + if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + srv.SetNetstackNet(netstackNet) + } + + listenAddr := netip.AddrPortFrom(netbirdIP, vncInternalPort) + network := e.wgInterface.Address().Network + if err := srv.Start(e.ctx, listenAddr, network); err != nil { + return fmt.Errorf("start VNC server: %w", err) + } + + e.vncSrv = srv + + if registrar, ok := e.firewall.(interface { + RegisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.RegisterNetstackService(nftypes.TCP, vncInternalPort) + log.Debugf("registered VNC service for TCP:%d", vncInternalPort) + } + + if err := e.setupVNCPortRedirection(); err != nil { + log.Warnf("setup VNC port redirection: %v", err) + } + + log.Info("VNC server enabled") + return nil +} + +// updateVNCServerJWT configures the JWT validation for the VNC server using +// the same JWT config as SSH (same identity provider). +func (e *Engine) updateVNCServerJWT(sshConf *mgmProto.SSHConfig) { + if e.vncSrv == nil { + return + } + + vncSrv, ok := e.vncSrv.(*vncserver.Server) + if !ok { + return + } + + protoJWT := sshConf.GetJwtConfig() + if protoJWT == nil { + return + } + + audiences := protoJWT.GetAudiences() + if len(audiences) == 0 && protoJWT.GetAudience() != "" { + audiences = []string{protoJWT.GetAudience()} + } + + vncSrv.SetJWTConfig(&vncserver.JWTConfig{ + Issuer: protoJWT.GetIssuer(), + Audiences: audiences, + KeysLocation: protoJWT.GetKeysLocation(), + MaxTokenAge: protoJWT.GetMaxTokenAge(), + }) +} + +// updateVNCServerAuth updates VNC fine-grained access control from management. +func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { + if vncAuth == nil || e.vncSrv == nil { + return + } + + vncSrv, ok := e.vncSrv.(*vncserver.Server) + if !ok { + return + } + + protoUsers := vncAuth.GetAuthorizedUsers() + authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers)) + for i, hash := range protoUsers { + if len(hash) != 16 { + log.Warnf("invalid VNC auth hash length %d, expected 16", len(hash)) + return + } + authorizedUsers[i] = sshuserhash.UserIDHash(hash) + } + + machineUsers := make(map[string][]uint32) + for osUser, indexes := range vncAuth.GetMachineUsers() { + machineUsers[osUser] = indexes.GetIndexes() + } + + vncSrv.UpdateVNCAuth(&sshauth.Config{ + UserIDClaim: vncAuth.GetUserIDClaim(), + AuthorizedUsers: authorizedUsers, + MachineUsers: machineUsers, + }) +} + +// GetVNCServerStatus returns whether the VNC server is running. +func (e *Engine) GetVNCServerStatus() bool { + return e.vncSrv != nil +} + +func (e *Engine) stopVNCServer() error { + if e.vncSrv == nil { + return nil + } + + if err := e.cleanupVNCPortRedirection(); err != nil { + log.Warnf("cleanup VNC port redirection: %v", err) + } + + if registrar, ok := e.firewall.(interface { + UnregisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.UnregisterNetstackService(nftypes.TCP, vncInternalPort) + } + + log.Info("stopping VNC server") + err := e.vncSrv.Stop() + e.vncSrv = nil + if err != nil { + return fmt.Errorf("stop VNC server: %w", err) + } + return nil +} diff --git a/client/internal/engine_vnc_console_freebsd.go b/client/internal/engine_vnc_console_freebsd.go new file mode 100644 index 00000000000..0d6b3a687d1 --- /dev/null +++ b/client/internal/engine_vnc_console_freebsd.go @@ -0,0 +1,31 @@ +//go:build freebsd + +package internal + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + + vncserver "github.com/netbirdio/netbird/client/vnc/server" +) + +// newConsoleVNC builds the FreeBSD console fallback: vt(4) framebuffer +// for capture, /dev/uinput for input. The uinput device requires the +// `uinput` kernel module (`kldload uinput`); without it, input init +// fails and we drop to a stub injector so the user still gets a +// view-only screen mirror. +func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) { + poller := vncserver.NewFBPoller("") + w, h := poller.Width(), poller.Height() + if w == 0 || h == 0 { + poller.Close() + return nil, nil, fmt.Errorf("vt framebuffer init failed (vt may not allow mmap on this driver)") + } + if inj, err := vncserver.NewUInputInjector(w, h); err == nil { + return poller, inj, nil + } else { + log.Infof("VNC console: uinput unavailable (%v); view-only mode. Run `kldload uinput` to enable input.", err) + return poller, &vncserver.StubInputInjector{}, nil + } +} diff --git a/client/internal/engine_vnc_console_linux.go b/client/internal/engine_vnc_console_linux.go new file mode 100644 index 00000000000..d2bdd24cef3 --- /dev/null +++ b/client/internal/engine_vnc_console_linux.go @@ -0,0 +1,28 @@ +//go:build linux && !android + +package internal + +import ( + "fmt" + + vncserver "github.com/netbirdio/netbird/client/vnc/server" +) + +// newConsoleVNC builds a framebuffer + uinput VNC backend for boxes +// without a running X server. Used as the auto-fallback when +// newPlatformVNC can't reach X. Returns an error when /dev/fb0 or +// /dev/uinput aren't usable so the caller can drop back to a stub. +func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) { + poller := vncserver.NewFBPoller("") + w, h := poller.Width(), poller.Height() + if w == 0 || h == 0 { + poller.Close() + return nil, nil, fmt.Errorf("framebuffer capturer init failed (is /dev/fb0 readable?)") + } + inj, err := vncserver.NewUInputInjector(w, h) + if err != nil { + poller.Close() + return nil, nil, fmt.Errorf("uinput init: %w", err) + } + return poller, inj, nil +} diff --git a/client/internal/engine_vnc_darwin.go b/client/internal/engine_vnc_darwin.go new file mode 100644 index 00000000000..7efe6f064f3 --- /dev/null +++ b/client/internal/engine_vnc_darwin.go @@ -0,0 +1,23 @@ +//go:build darwin && !ios + +package internal + +import ( + log "github.com/sirupsen/logrus" + + vncserver "github.com/netbirdio/netbird/client/vnc/server" +) + +func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { + capturer := vncserver.NewMacPoller() + injector, err := vncserver.NewMacInputInjector() + if err != nil { + log.Debugf("VNC: macOS input injector: %v", err) + return capturer, &vncserver.StubInputInjector{}, true + } + return capturer, injector, true +} + +func vncNeedsServiceMode() bool { + return false +} diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go new file mode 100644 index 00000000000..8ef16803d55 --- /dev/null +++ b/client/internal/engine_vnc_stub.go @@ -0,0 +1,13 @@ +//go:build (!windows && !darwin && !freebsd && !(linux && !android)) || (darwin && ios) + +package internal + +import vncserver "github.com/netbirdio/netbird/client/vnc/server" + +func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { + return nil, nil, false +} + +func vncNeedsServiceMode() bool { + return false +} diff --git a/client/internal/engine_vnc_windows.go b/client/internal/engine_vnc_windows.go new file mode 100644 index 00000000000..57f5fbbc7ff --- /dev/null +++ b/client/internal/engine_vnc_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package internal + +import vncserver "github.com/netbirdio/netbird/client/vnc/server" + +func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { + return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), true +} + +func vncNeedsServiceMode() bool { + return vncserver.GetCurrentSessionID() == 0 +} diff --git a/client/internal/engine_vnc_x11.go b/client/internal/engine_vnc_x11.go new file mode 100644 index 00000000000..d74bd17ab23 --- /dev/null +++ b/client/internal/engine_vnc_x11.go @@ -0,0 +1,35 @@ +//go:build (linux && !android) || freebsd + +package internal + +import ( + log "github.com/sirupsen/logrus" + + vncserver "github.com/netbirdio/netbird/client/vnc/server" +) + +func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { + // Prefer X11 when an X server is reachable. NewX11InputInjector probes + // DISPLAY (and /proc) eagerly, so a non-nil error here means no X. + injector, err := vncserver.NewX11InputInjector("") + if err == nil { + return vncserver.NewX11Poller(""), injector, true + } + log.Debugf("VNC: X11 not available: %v", err) + + // Fallback for headless / pre-X states (kernel console, login manager + // without X, physical server in recovery): stream the framebuffer and + // inject input via /dev/uinput. + consoleCap, consoleInj, err := newConsoleVNC() + if err == nil { + log.Infof("VNC: using framebuffer console capture (%dx%d)", consoleCap.Width(), consoleCap.Height()) + return consoleCap, consoleInj, true + } + log.Debugf("VNC: framebuffer console fallback unavailable: %v", err) + + return &vncserver.StubCapturer{}, &vncserver.StubInputInjector{}, false +} + +func vncNeedsServiceMode() bool { + return false +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index cd5bc068075..2d98e8cf784 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -65,6 +65,7 @@ type ConfigInput struct { StateFilePath string PreSharedKey *string ServerSSHAllowed *bool + ServerVNCAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -116,6 +117,7 @@ type Config struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed *bool + ServerVNCAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -418,6 +420,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.ServerVNCAllowed != nil { + if config.ServerVNCAllowed == nil || *input.ServerVNCAllowed != *config.ServerVNCAllowed { + if *input.ServerVNCAllowed { + log.Infof("enabling VNC server") + } else { + log.Infof("disabling VNC server") + } + config.ServerVNCAllowed = input.ServerVNCAllowed + updated = true + } + } else if config.ServerVNCAllowed == nil { + config.ServerVNCAllowed = util.True() + updated = true + } + if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go index 2c9e46290be..7d4cf3debdd 100644 --- a/client/internal/statemanager/manager.go +++ b/client/internal/statemanager/manager.go @@ -74,6 +74,14 @@ func New(filePath string) *Manager { } } +// FilePath returns the path of the underlying state file. +func (m *Manager) FilePath() string { + if m == nil { + return "" + } + return m.filePath +} + // Start starts the state manager periodic save routine func (m *Manager) Start() { if m == nil { diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 2c054c99a72..4ba4976753e 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -192,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Severity.Descriptor instead. func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51, 0} + return file_daemon_proto_rawDescGZIP(), []int{52, 0} } type SystemEvent_Category int32 @@ -247,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Category.Descriptor instead. func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51, 1} + return file_daemon_proto_rawDescGZIP(), []int{52, 1} } type EmptyRequest struct { @@ -343,6 +343,7 @@ type LoginRequest struct { DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + ServerVNCAllowed *bool `protobuf:"varint,41,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -658,6 +659,13 @@ func (x *LoginRequest) GetDisableIpv6() bool { return false } +func (x *LoginRequest) GetServerVNCAllowed() bool { + if x != nil && x.ServerVNCAllowed != nil { + return *x.ServerVNCAllowed + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -1191,6 +1199,7 @@ type GetConfigResponse struct { DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` + ServerVNCAllowed bool `protobuf:"varint,28,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1414,6 +1423,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool { return false } +func (x *GetConfigResponse) GetServerVNCAllowed() bool { + if x != nil { + return x.ServerVNCAllowed + } + return false +} + // PeerState contains the latest state of a peer type PeerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2085,6 +2101,51 @@ func (x *SSHServerState) GetSessions() []*SSHSessionInfo { return nil } +// VNCServerState contains the latest state of the VNC server +type VNCServerState struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VNCServerState) Reset() { + *x = VNCServerState{} + mi := &file_daemon_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VNCServerState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VNCServerState) ProtoMessage() {} + +func (x *VNCServerState) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VNCServerState.ProtoReflect.Descriptor instead. +func (*VNCServerState) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{21} +} + +func (x *VNCServerState) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + // FullStatus contains the full state held by the Status instance type FullStatus struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2098,13 +2159,14 @@ type FullStatus struct { Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` + VncServerState *VNCServerState `protobuf:"bytes,11,opt,name=vncServerState,proto3" json:"vncServerState,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *FullStatus) Reset() { *x = FullStatus{} - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2116,7 +2178,7 @@ func (x *FullStatus) String() string { func (*FullStatus) ProtoMessage() {} func (x *FullStatus) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2129,7 +2191,7 @@ func (x *FullStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use FullStatus.ProtoReflect.Descriptor instead. func (*FullStatus) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{21} + return file_daemon_proto_rawDescGZIP(), []int{22} } func (x *FullStatus) GetManagementState() *ManagementState { @@ -2202,6 +2264,13 @@ func (x *FullStatus) GetSshServerState() *SSHServerState { return nil } +func (x *FullStatus) GetVncServerState() *VNCServerState { + if x != nil { + return x.VncServerState + } + return nil +} + // Networks type ListNetworksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2211,7 +2280,7 @@ type ListNetworksRequest struct { func (x *ListNetworksRequest) Reset() { *x = ListNetworksRequest{} - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2223,7 +2292,7 @@ func (x *ListNetworksRequest) String() string { func (*ListNetworksRequest) ProtoMessage() {} func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2236,7 +2305,7 @@ func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksRequest.ProtoReflect.Descriptor instead. func (*ListNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{22} + return file_daemon_proto_rawDescGZIP(), []int{23} } type ListNetworksResponse struct { @@ -2248,7 +2317,7 @@ type ListNetworksResponse struct { func (x *ListNetworksResponse) Reset() { *x = ListNetworksResponse{} - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2260,7 +2329,7 @@ func (x *ListNetworksResponse) String() string { func (*ListNetworksResponse) ProtoMessage() {} func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2273,7 +2342,7 @@ func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksResponse.ProtoReflect.Descriptor instead. func (*ListNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{23} + return file_daemon_proto_rawDescGZIP(), []int{24} } func (x *ListNetworksResponse) GetRoutes() []*Network { @@ -2294,7 +2363,7 @@ type SelectNetworksRequest struct { func (x *SelectNetworksRequest) Reset() { *x = SelectNetworksRequest{} - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2306,7 +2375,7 @@ func (x *SelectNetworksRequest) String() string { func (*SelectNetworksRequest) ProtoMessage() {} func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2319,7 +2388,7 @@ func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksRequest.ProtoReflect.Descriptor instead. func (*SelectNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{24} + return file_daemon_proto_rawDescGZIP(), []int{25} } func (x *SelectNetworksRequest) GetNetworkIDs() []string { @@ -2351,7 +2420,7 @@ type SelectNetworksResponse struct { func (x *SelectNetworksResponse) Reset() { *x = SelectNetworksResponse{} - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2363,7 +2432,7 @@ func (x *SelectNetworksResponse) String() string { func (*SelectNetworksResponse) ProtoMessage() {} func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2376,7 +2445,7 @@ func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksResponse.ProtoReflect.Descriptor instead. func (*SelectNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{25} + return file_daemon_proto_rawDescGZIP(), []int{26} } type IPList struct { @@ -2388,7 +2457,7 @@ type IPList struct { func (x *IPList) Reset() { *x = IPList{} - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2400,7 +2469,7 @@ func (x *IPList) String() string { func (*IPList) ProtoMessage() {} func (x *IPList) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2413,7 +2482,7 @@ func (x *IPList) ProtoReflect() protoreflect.Message { // Deprecated: Use IPList.ProtoReflect.Descriptor instead. func (*IPList) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{26} + return file_daemon_proto_rawDescGZIP(), []int{27} } func (x *IPList) GetIps() []string { @@ -2436,7 +2505,7 @@ type Network struct { func (x *Network) Reset() { *x = Network{} - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2448,7 +2517,7 @@ func (x *Network) String() string { func (*Network) ProtoMessage() {} func (x *Network) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2461,7 +2530,7 @@ func (x *Network) ProtoReflect() protoreflect.Message { // Deprecated: Use Network.ProtoReflect.Descriptor instead. func (*Network) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{27} + return file_daemon_proto_rawDescGZIP(), []int{28} } func (x *Network) GetID() string { @@ -2513,7 +2582,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2525,7 +2594,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2538,7 +2607,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{28} + return file_daemon_proto_rawDescGZIP(), []int{29} } func (x *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -2595,7 +2664,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2607,7 +2676,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2620,7 +2689,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{29} + return file_daemon_proto_rawDescGZIP(), []int{30} } func (x *ForwardingRule) GetProtocol() string { @@ -2667,7 +2736,7 @@ type ForwardingRulesResponse struct { func (x *ForwardingRulesResponse) Reset() { *x = ForwardingRulesResponse{} - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2679,7 +2748,7 @@ func (x *ForwardingRulesResponse) String() string { func (*ForwardingRulesResponse) ProtoMessage() {} func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2692,7 +2761,7 @@ func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRulesResponse.ProtoReflect.Descriptor instead. func (*ForwardingRulesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{30} + return file_daemon_proto_rawDescGZIP(), []int{31} } func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { @@ -2715,7 +2784,7 @@ type DebugBundleRequest struct { func (x *DebugBundleRequest) Reset() { *x = DebugBundleRequest{} - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2727,7 +2796,7 @@ func (x *DebugBundleRequest) String() string { func (*DebugBundleRequest) ProtoMessage() {} func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2740,7 +2809,7 @@ func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleRequest.ProtoReflect.Descriptor instead. func (*DebugBundleRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{31} + return file_daemon_proto_rawDescGZIP(), []int{32} } func (x *DebugBundleRequest) GetAnonymize() bool { @@ -2782,7 +2851,7 @@ type DebugBundleResponse struct { func (x *DebugBundleResponse) Reset() { *x = DebugBundleResponse{} - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2794,7 +2863,7 @@ func (x *DebugBundleResponse) String() string { func (*DebugBundleResponse) ProtoMessage() {} func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2807,7 +2876,7 @@ func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleResponse.ProtoReflect.Descriptor instead. func (*DebugBundleResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{32} + return file_daemon_proto_rawDescGZIP(), []int{33} } func (x *DebugBundleResponse) GetPath() string { @@ -2839,7 +2908,7 @@ type GetLogLevelRequest struct { func (x *GetLogLevelRequest) Reset() { *x = GetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2851,7 +2920,7 @@ func (x *GetLogLevelRequest) String() string { func (*GetLogLevelRequest) ProtoMessage() {} func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2864,7 +2933,7 @@ func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelRequest.ProtoReflect.Descriptor instead. func (*GetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{33} + return file_daemon_proto_rawDescGZIP(), []int{34} } type GetLogLevelResponse struct { @@ -2876,7 +2945,7 @@ type GetLogLevelResponse struct { func (x *GetLogLevelResponse) Reset() { *x = GetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2888,7 +2957,7 @@ func (x *GetLogLevelResponse) String() string { func (*GetLogLevelResponse) ProtoMessage() {} func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2901,7 +2970,7 @@ func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelResponse.ProtoReflect.Descriptor instead. func (*GetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{34} + return file_daemon_proto_rawDescGZIP(), []int{35} } func (x *GetLogLevelResponse) GetLevel() LogLevel { @@ -2920,7 +2989,7 @@ type SetLogLevelRequest struct { func (x *SetLogLevelRequest) Reset() { *x = SetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2932,7 +3001,7 @@ func (x *SetLogLevelRequest) String() string { func (*SetLogLevelRequest) ProtoMessage() {} func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2945,7 +3014,7 @@ func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelRequest.ProtoReflect.Descriptor instead. func (*SetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{35} + return file_daemon_proto_rawDescGZIP(), []int{36} } func (x *SetLogLevelRequest) GetLevel() LogLevel { @@ -2963,7 +3032,7 @@ type SetLogLevelResponse struct { func (x *SetLogLevelResponse) Reset() { *x = SetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2975,7 +3044,7 @@ func (x *SetLogLevelResponse) String() string { func (*SetLogLevelResponse) ProtoMessage() {} func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2988,7 +3057,7 @@ func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelResponse.ProtoReflect.Descriptor instead. func (*SetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{36} + return file_daemon_proto_rawDescGZIP(), []int{37} } // State represents a daemon state entry @@ -3001,7 +3070,7 @@ type State struct { func (x *State) Reset() { *x = State{} - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3013,7 +3082,7 @@ func (x *State) String() string { func (*State) ProtoMessage() {} func (x *State) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3026,7 +3095,7 @@ func (x *State) ProtoReflect() protoreflect.Message { // Deprecated: Use State.ProtoReflect.Descriptor instead. func (*State) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{37} + return file_daemon_proto_rawDescGZIP(), []int{38} } func (x *State) GetName() string { @@ -3045,7 +3114,7 @@ type ListStatesRequest struct { func (x *ListStatesRequest) Reset() { *x = ListStatesRequest{} - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3057,7 +3126,7 @@ func (x *ListStatesRequest) String() string { func (*ListStatesRequest) ProtoMessage() {} func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3070,7 +3139,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead. func (*ListStatesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{38} + return file_daemon_proto_rawDescGZIP(), []int{39} } // ListStatesResponse contains a list of states @@ -3083,7 +3152,7 @@ type ListStatesResponse struct { func (x *ListStatesResponse) Reset() { *x = ListStatesResponse{} - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3095,7 +3164,7 @@ func (x *ListStatesResponse) String() string { func (*ListStatesResponse) ProtoMessage() {} func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3108,7 +3177,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead. func (*ListStatesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{39} + return file_daemon_proto_rawDescGZIP(), []int{40} } func (x *ListStatesResponse) GetStates() []*State { @@ -3129,7 +3198,7 @@ type CleanStateRequest struct { func (x *CleanStateRequest) Reset() { *x = CleanStateRequest{} - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3141,7 +3210,7 @@ func (x *CleanStateRequest) String() string { func (*CleanStateRequest) ProtoMessage() {} func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3154,7 +3223,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead. func (*CleanStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{40} + return file_daemon_proto_rawDescGZIP(), []int{41} } func (x *CleanStateRequest) GetStateName() string { @@ -3181,7 +3250,7 @@ type CleanStateResponse struct { func (x *CleanStateResponse) Reset() { *x = CleanStateResponse{} - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3193,7 +3262,7 @@ func (x *CleanStateResponse) String() string { func (*CleanStateResponse) ProtoMessage() {} func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3206,7 +3275,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead. func (*CleanStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{41} + return file_daemon_proto_rawDescGZIP(), []int{42} } func (x *CleanStateResponse) GetCleanedStates() int32 { @@ -3227,7 +3296,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3239,7 +3308,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3252,7 +3321,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{42} + return file_daemon_proto_rawDescGZIP(), []int{43} } func (x *DeleteStateRequest) GetStateName() string { @@ -3279,7 +3348,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3291,7 +3360,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3304,7 +3373,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{43} + return file_daemon_proto_rawDescGZIP(), []int{44} } func (x *DeleteStateResponse) GetDeletedStates() int32 { @@ -3323,7 +3392,7 @@ type SetSyncResponsePersistenceRequest struct { func (x *SetSyncResponsePersistenceRequest) Reset() { *x = SetSyncResponsePersistenceRequest{} - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3335,7 +3404,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string { func (*SetSyncResponsePersistenceRequest) ProtoMessage() {} func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3348,7 +3417,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{44} + return file_daemon_proto_rawDescGZIP(), []int{45} } func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool { @@ -3366,7 +3435,7 @@ type SetSyncResponsePersistenceResponse struct { func (x *SetSyncResponsePersistenceResponse) Reset() { *x = SetSyncResponsePersistenceResponse{} - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3378,7 +3447,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string { func (*SetSyncResponsePersistenceResponse) ProtoMessage() {} func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3391,7 +3460,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{45} + return file_daemon_proto_rawDescGZIP(), []int{46} } type TCPFlags struct { @@ -3408,7 +3477,7 @@ type TCPFlags struct { func (x *TCPFlags) Reset() { *x = TCPFlags{} - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3420,7 +3489,7 @@ func (x *TCPFlags) String() string { func (*TCPFlags) ProtoMessage() {} func (x *TCPFlags) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3433,7 +3502,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead. func (*TCPFlags) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{46} + return file_daemon_proto_rawDescGZIP(), []int{47} } func (x *TCPFlags) GetSyn() bool { @@ -3495,7 +3564,7 @@ type TracePacketRequest struct { func (x *TracePacketRequest) Reset() { *x = TracePacketRequest{} - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3507,7 +3576,7 @@ func (x *TracePacketRequest) String() string { func (*TracePacketRequest) ProtoMessage() {} func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3520,7 +3589,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead. func (*TracePacketRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{47} + return file_daemon_proto_rawDescGZIP(), []int{48} } func (x *TracePacketRequest) GetSourceIp() string { @@ -3598,7 +3667,7 @@ type TraceStage struct { func (x *TraceStage) Reset() { *x = TraceStage{} - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3610,7 +3679,7 @@ func (x *TraceStage) String() string { func (*TraceStage) ProtoMessage() {} func (x *TraceStage) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3623,7 +3692,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message { // Deprecated: Use TraceStage.ProtoReflect.Descriptor instead. func (*TraceStage) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{48} + return file_daemon_proto_rawDescGZIP(), []int{49} } func (x *TraceStage) GetName() string { @@ -3664,7 +3733,7 @@ type TracePacketResponse struct { func (x *TracePacketResponse) Reset() { *x = TracePacketResponse{} - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3676,7 +3745,7 @@ func (x *TracePacketResponse) String() string { func (*TracePacketResponse) ProtoMessage() {} func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3689,7 +3758,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead. func (*TracePacketResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49} + return file_daemon_proto_rawDescGZIP(), []int{50} } func (x *TracePacketResponse) GetStages() []*TraceStage { @@ -3714,7 +3783,7 @@ type SubscribeRequest struct { func (x *SubscribeRequest) Reset() { *x = SubscribeRequest{} - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3726,7 +3795,7 @@ func (x *SubscribeRequest) String() string { func (*SubscribeRequest) ProtoMessage() {} func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3739,7 +3808,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. func (*SubscribeRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{50} + return file_daemon_proto_rawDescGZIP(), []int{51} } type SystemEvent struct { @@ -3757,7 +3826,7 @@ type SystemEvent struct { func (x *SystemEvent) Reset() { *x = SystemEvent{} - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3769,7 +3838,7 @@ func (x *SystemEvent) String() string { func (*SystemEvent) ProtoMessage() {} func (x *SystemEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3782,7 +3851,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead. func (*SystemEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51} + return file_daemon_proto_rawDescGZIP(), []int{52} } func (x *SystemEvent) GetId() string { @@ -3842,7 +3911,7 @@ type GetEventsRequest struct { func (x *GetEventsRequest) Reset() { *x = GetEventsRequest{} - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3854,7 +3923,7 @@ func (x *GetEventsRequest) String() string { func (*GetEventsRequest) ProtoMessage() {} func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3867,7 +3936,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead. func (*GetEventsRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52} + return file_daemon_proto_rawDescGZIP(), []int{53} } type GetEventsResponse struct { @@ -3879,7 +3948,7 @@ type GetEventsResponse struct { func (x *GetEventsResponse) Reset() { *x = GetEventsResponse{} - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3891,7 +3960,7 @@ func (x *GetEventsResponse) String() string { func (*GetEventsResponse) ProtoMessage() {} func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3904,7 +3973,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead. func (*GetEventsResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53} + return file_daemon_proto_rawDescGZIP(), []int{54} } func (x *GetEventsResponse) GetEvents() []*SystemEvent { @@ -3924,7 +3993,7 @@ type SwitchProfileRequest struct { func (x *SwitchProfileRequest) Reset() { *x = SwitchProfileRequest{} - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3936,7 +4005,7 @@ func (x *SwitchProfileRequest) String() string { func (*SwitchProfileRequest) ProtoMessage() {} func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3949,7 +4018,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead. func (*SwitchProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{54} + return file_daemon_proto_rawDescGZIP(), []int{55} } func (x *SwitchProfileRequest) GetProfileName() string { @@ -3974,7 +4043,7 @@ type SwitchProfileResponse struct { func (x *SwitchProfileResponse) Reset() { *x = SwitchProfileResponse{} - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3986,7 +4055,7 @@ func (x *SwitchProfileResponse) String() string { func (*SwitchProfileResponse) ProtoMessage() {} func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3999,7 +4068,7 @@ func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileResponse.ProtoReflect.Descriptor instead. func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{55} + return file_daemon_proto_rawDescGZIP(), []int{56} } type SetConfigRequest struct { @@ -4042,13 +4111,14 @@ type SetConfigRequest struct { DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + ServerVNCAllowed *bool `protobuf:"varint,36,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4060,7 +4130,7 @@ func (x *SetConfigRequest) String() string { func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4073,7 +4143,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{56} + return file_daemon_proto_rawDescGZIP(), []int{57} } func (x *SetConfigRequest) GetUsername() string { @@ -4321,6 +4391,13 @@ func (x *SetConfigRequest) GetDisableIpv6() bool { return false } +func (x *SetConfigRequest) GetServerVNCAllowed() bool { + if x != nil && x.ServerVNCAllowed != nil { + return *x.ServerVNCAllowed + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4329,7 +4406,7 @@ type SetConfigResponse struct { func (x *SetConfigResponse) Reset() { *x = SetConfigResponse{} - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4341,7 +4418,7 @@ func (x *SetConfigResponse) String() string { func (*SetConfigResponse) ProtoMessage() {} func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4354,7 +4431,7 @@ func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead. func (*SetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{57} + return file_daemon_proto_rawDescGZIP(), []int{58} } type AddProfileRequest struct { @@ -4367,7 +4444,7 @@ type AddProfileRequest struct { func (x *AddProfileRequest) Reset() { *x = AddProfileRequest{} - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4379,7 +4456,7 @@ func (x *AddProfileRequest) String() string { func (*AddProfileRequest) ProtoMessage() {} func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4392,7 +4469,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead. func (*AddProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{58} + return file_daemon_proto_rawDescGZIP(), []int{59} } func (x *AddProfileRequest) GetUsername() string { @@ -4417,7 +4494,7 @@ type AddProfileResponse struct { func (x *AddProfileResponse) Reset() { *x = AddProfileResponse{} - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4429,7 +4506,7 @@ func (x *AddProfileResponse) String() string { func (*AddProfileResponse) ProtoMessage() {} func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4442,7 +4519,7 @@ func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileResponse.ProtoReflect.Descriptor instead. func (*AddProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{59} + return file_daemon_proto_rawDescGZIP(), []int{60} } type RemoveProfileRequest struct { @@ -4455,7 +4532,7 @@ type RemoveProfileRequest struct { func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4467,7 +4544,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4480,7 +4557,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{61} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4505,7 +4582,7 @@ type RemoveProfileResponse struct { func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4517,7 +4594,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4530,7 +4607,7 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{62} } type ListProfilesRequest struct { @@ -4542,7 +4619,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4554,7 +4631,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4567,7 +4644,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{63} } func (x *ListProfilesRequest) GetUsername() string { @@ -4586,7 +4663,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4598,7 +4675,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4611,7 +4688,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4631,7 +4708,7 @@ type Profile struct { func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4643,7 +4720,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4656,7 +4733,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{65} } func (x *Profile) GetName() string { @@ -4681,7 +4758,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4693,7 +4770,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4706,7 +4783,7 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{66} } type GetActiveProfileResponse struct { @@ -4719,7 +4796,7 @@ type GetActiveProfileResponse struct { func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4731,7 +4808,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4744,7 +4821,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{67} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4771,7 +4848,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4783,7 +4860,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4796,7 +4873,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *LogoutRequest) GetProfileName() string { @@ -4821,7 +4898,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4833,7 +4910,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4846,7 +4923,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{69} } type GetFeaturesRequest struct { @@ -4857,7 +4934,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4869,7 +4946,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4882,7 +4959,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{70} } type GetFeaturesResponse struct { @@ -4896,7 +4973,7 @@ type GetFeaturesResponse struct { func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4908,7 +4985,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4921,7 +4998,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{71} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -4953,7 +5030,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4965,7 +5042,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4978,7 +5055,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{72} } type TriggerUpdateResponse struct { @@ -4991,7 +5068,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5003,7 +5080,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5016,7 +5093,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{73} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5044,7 +5121,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5056,7 +5133,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5069,7 +5146,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{74} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5096,7 +5173,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5108,7 +5185,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5121,7 +5198,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5163,7 +5240,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5175,7 +5252,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5188,7 +5265,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5221,7 +5298,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5233,7 +5310,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5246,7 +5323,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5311,7 +5388,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5323,7 +5400,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5336,7 +5413,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5368,7 +5445,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5380,7 +5457,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5393,7 +5470,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5426,7 +5503,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5438,7 +5515,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5451,7 +5528,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{80} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5463,7 +5540,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5475,7 +5552,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5488,7 +5565,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{81} } // StopCPUProfileRequest for stopping CPU profiling @@ -5500,7 +5577,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5512,7 +5589,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5525,7 +5602,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{82} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5537,7 +5614,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5549,7 +5626,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5562,7 +5639,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{83} } type InstallerResultRequest struct { @@ -5573,7 +5650,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5585,7 +5662,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5598,7 +5675,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{84} } type InstallerResultResponse struct { @@ -5611,7 +5688,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5623,7 +5700,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5636,7 +5713,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{85} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5669,7 +5746,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5681,7 +5758,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5694,7 +5771,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{86} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5765,7 +5842,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5777,7 +5854,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5790,7 +5867,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5831,7 +5908,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5843,7 +5920,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5856,7 +5933,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{88} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5901,7 +5978,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5913,7 +5990,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5926,7 +6003,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{89} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -5980,7 +6057,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5992,7 +6069,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6005,7 +6082,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{90} } func (x *CapturePacket) GetData() []byte { @@ -6026,7 +6103,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6038,7 +6115,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6051,7 +6128,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{91} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6069,7 +6146,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6081,7 +6158,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6094,7 +6171,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{92} } type StopBundleCaptureRequest struct { @@ -6105,7 +6182,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6117,7 +6194,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6130,7 +6207,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{93} } type StopBundleCaptureResponse struct { @@ -6141,7 +6218,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6153,7 +6230,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6166,7 +6243,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{94} } type PortInfo_Range struct { @@ -6179,7 +6256,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6191,7 +6268,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6204,7 +6281,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead. func (*PortInfo_Range) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{28, 0} + return file_daemon_proto_rawDescGZIP(), []int{29, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -6226,7 +6303,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xef\x12\n" + + "\fEmptyRequest\"\xb5\x13\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -6271,7 +6348,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x12/\n" + + "\x10serverVNCAllowed\x18) \x01(\bH\x1cR\x10serverVNCAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6299,7 +6377,8 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\xb5\x01\n" + + "\r_disable_ipv6B\x13\n" + + "\x11_serverVNCAllowed\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -6332,7 +6411,7 @@ const file_daemon_proto_rawDesc = "" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xfe\b\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -6364,7 +6443,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + - "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\"\x92\x06\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + + "\x10serverVNCAllowed\x18\x1c \x01(\bR\x10serverVNCAllowed\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" + @@ -6425,7 +6505,9 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"*\n" + + "\x0eVNCServerState\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\"\xef\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -6439,7 +6521,8 @@ const file_daemon_proto_rawDesc = "" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + - " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" + + " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12>\n" + + "\x0evncServerState\x18\v \x01(\v2\x16.daemon.VNCServerStateR\x0evncServerState\"\x15\n" + "\x13ListNetworksRequest\"?\n" + "\x14ListNetworksResponse\x12'\n" + "\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" + @@ -6579,7 +6662,7 @@ const file_daemon_proto_rawDesc = "" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + "\t_username\"\x17\n" + - "\x15SwitchProfileResponse\"\x98\x11\n" + + "\x15SwitchProfileResponse\"\xde\x11\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -6619,7 +6702,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x12/\n" + + "\x10serverVNCAllowed\x18$ \x01(\bH\x19R\x10serverVNCAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6644,7 +6728,8 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\x13\n" + + "\r_disable_ipv6B\x13\n" + + "\x11_serverVNCAllowed\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + @@ -6831,7 +6916,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 97) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -6858,91 +6943,92 @@ var file_daemon_proto_goTypes = []any{ (*NSGroupState)(nil), // 22: daemon.NSGroupState (*SSHSessionInfo)(nil), // 23: daemon.SSHSessionInfo (*SSHServerState)(nil), // 24: daemon.SSHServerState - (*FullStatus)(nil), // 25: daemon.FullStatus - (*ListNetworksRequest)(nil), // 26: daemon.ListNetworksRequest - (*ListNetworksResponse)(nil), // 27: daemon.ListNetworksResponse - (*SelectNetworksRequest)(nil), // 28: daemon.SelectNetworksRequest - (*SelectNetworksResponse)(nil), // 29: daemon.SelectNetworksResponse - (*IPList)(nil), // 30: daemon.IPList - (*Network)(nil), // 31: daemon.Network - (*PortInfo)(nil), // 32: daemon.PortInfo - (*ForwardingRule)(nil), // 33: daemon.ForwardingRule - (*ForwardingRulesResponse)(nil), // 34: daemon.ForwardingRulesResponse - (*DebugBundleRequest)(nil), // 35: daemon.DebugBundleRequest - (*DebugBundleResponse)(nil), // 36: daemon.DebugBundleResponse - (*GetLogLevelRequest)(nil), // 37: daemon.GetLogLevelRequest - (*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse - (*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest - (*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse - (*State)(nil), // 41: daemon.State - (*ListStatesRequest)(nil), // 42: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 43: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 44: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 45: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 46: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 47: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 48: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 49: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 50: daemon.TCPFlags - (*TracePacketRequest)(nil), // 51: daemon.TracePacketRequest - (*TraceStage)(nil), // 52: daemon.TraceStage - (*TracePacketResponse)(nil), // 53: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 54: daemon.SubscribeRequest - (*SystemEvent)(nil), // 55: daemon.SystemEvent - (*GetEventsRequest)(nil), // 56: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 57: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 58: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 59: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 60: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse - (*Profile)(nil), // 68: daemon.Profile - (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 71: daemon.LogoutRequest - (*LogoutResponse)(nil), // 72: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse - (*TriggerUpdateRequest)(nil), // 75: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 76: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 89: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 90: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 91: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 92: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 93: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 94: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 95: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 96: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 97: daemon.StopBundleCaptureResponse - nil, // 98: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 99: daemon.PortInfo.Range - nil, // 100: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 101: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 102: google.protobuf.Timestamp + (*VNCServerState)(nil), // 25: daemon.VNCServerState + (*FullStatus)(nil), // 26: daemon.FullStatus + (*ListNetworksRequest)(nil), // 27: daemon.ListNetworksRequest + (*ListNetworksResponse)(nil), // 28: daemon.ListNetworksResponse + (*SelectNetworksRequest)(nil), // 29: daemon.SelectNetworksRequest + (*SelectNetworksResponse)(nil), // 30: daemon.SelectNetworksResponse + (*IPList)(nil), // 31: daemon.IPList + (*Network)(nil), // 32: daemon.Network + (*PortInfo)(nil), // 33: daemon.PortInfo + (*ForwardingRule)(nil), // 34: daemon.ForwardingRule + (*ForwardingRulesResponse)(nil), // 35: daemon.ForwardingRulesResponse + (*DebugBundleRequest)(nil), // 36: daemon.DebugBundleRequest + (*DebugBundleResponse)(nil), // 37: daemon.DebugBundleResponse + (*GetLogLevelRequest)(nil), // 38: daemon.GetLogLevelRequest + (*GetLogLevelResponse)(nil), // 39: daemon.GetLogLevelResponse + (*SetLogLevelRequest)(nil), // 40: daemon.SetLogLevelRequest + (*SetLogLevelResponse)(nil), // 41: daemon.SetLogLevelResponse + (*State)(nil), // 42: daemon.State + (*ListStatesRequest)(nil), // 43: daemon.ListStatesRequest + (*ListStatesResponse)(nil), // 44: daemon.ListStatesResponse + (*CleanStateRequest)(nil), // 45: daemon.CleanStateRequest + (*CleanStateResponse)(nil), // 46: daemon.CleanStateResponse + (*DeleteStateRequest)(nil), // 47: daemon.DeleteStateRequest + (*DeleteStateResponse)(nil), // 48: daemon.DeleteStateResponse + (*SetSyncResponsePersistenceRequest)(nil), // 49: daemon.SetSyncResponsePersistenceRequest + (*SetSyncResponsePersistenceResponse)(nil), // 50: daemon.SetSyncResponsePersistenceResponse + (*TCPFlags)(nil), // 51: daemon.TCPFlags + (*TracePacketRequest)(nil), // 52: daemon.TracePacketRequest + (*TraceStage)(nil), // 53: daemon.TraceStage + (*TracePacketResponse)(nil), // 54: daemon.TracePacketResponse + (*SubscribeRequest)(nil), // 55: daemon.SubscribeRequest + (*SystemEvent)(nil), // 56: daemon.SystemEvent + (*GetEventsRequest)(nil), // 57: daemon.GetEventsRequest + (*GetEventsResponse)(nil), // 58: daemon.GetEventsResponse + (*SwitchProfileRequest)(nil), // 59: daemon.SwitchProfileRequest + (*SwitchProfileResponse)(nil), // 60: daemon.SwitchProfileResponse + (*SetConfigRequest)(nil), // 61: daemon.SetConfigRequest + (*SetConfigResponse)(nil), // 62: daemon.SetConfigResponse + (*AddProfileRequest)(nil), // 63: daemon.AddProfileRequest + (*AddProfileResponse)(nil), // 64: daemon.AddProfileResponse + (*RemoveProfileRequest)(nil), // 65: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 66: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 67: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 68: daemon.ListProfilesResponse + (*Profile)(nil), // 69: daemon.Profile + (*GetActiveProfileRequest)(nil), // 70: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 71: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 72: daemon.LogoutRequest + (*LogoutResponse)(nil), // 73: daemon.LogoutResponse + (*GetFeaturesRequest)(nil), // 74: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 75: daemon.GetFeaturesResponse + (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 94: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse + nil, // 99: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range + nil, // 101: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 102: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 101, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 102, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 102, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 101, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 26, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus + 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState @@ -6950,114 +7036,115 @@ var file_daemon_proto_depIdxs = []int32{ 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 56, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState - 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 98, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 99, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule - 0, // 20: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel - 0, // 21: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 41, // 22: daemon.ListStatesResponse.states:type_name -> daemon.State - 50, // 23: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 102, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 100, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 101, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 101, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 101, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration - 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 9, // 39: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 11, // 40: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 13, // 41: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 15, // 42: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 26, // 43: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 28, // 44: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 28, // 45: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 46: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 35, // 47: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 37, // 48: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 39, // 49: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 42, // 50: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 44, // 51: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 92, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 94, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 96, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest - 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 75, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 77, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 79, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 81, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 83, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 85, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 87, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 89, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 93, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 95, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 97, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 76, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 78, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 80, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 82, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 84, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 86, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 88, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 90, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 76, // [76:115] is the sub-list for method output_type - 37, // [37:76] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 25, // 14: daemon.FullStatus.vncServerState:type_name -> daemon.VNCServerState + 32, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 99, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 100, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 33, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 33, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 34, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel + 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel + 42, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State + 51, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 53, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 103, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 101, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 56, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 102, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 69, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 92, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 102, // 35: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 102, // 36: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 31, // 37: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 38: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 39: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 40: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 41: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 13, // 42: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 43: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 27, // 44: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 29, // 45: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 29, // 46: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 47: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 36, // 48: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 38, // 49: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 40, // 50: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 43, // 51: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 45, // 52: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 47, // 53: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 49, // 54: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 52, // 55: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 93, // 56: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 95, // 57: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 97, // 58: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 55, // 59: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 57, // 60: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 59, // 61: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 61, // 62: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 63, // 63: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 65, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 67, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 70, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 72, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 74, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 76, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 78, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 80, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 82, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 84, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 86, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 88, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 90, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 28, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 30, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 30, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 35, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 37, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 39, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 41, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 44, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 46, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 48, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 50, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 54, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 94, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 96, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 98, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 56, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 58, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 60, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 62, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 64, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 66, // 103: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 68, // 104: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 71, // 105: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 73, // 106: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 75, // 107: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 77, // 108: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 79, // 109: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 81, // 110: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 83, // 111: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 85, // 112: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 87, // 113: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 89, // 114: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 91, // 115: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 77, // [77:116] is the sub-list for method output_type + 38, // [38:77] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -7068,17 +7155,17 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[1].OneofWrappers = []any{} file_daemon_proto_msgTypes[5].OneofWrappers = []any{} file_daemon_proto_msgTypes[7].OneofWrappers = []any{} - file_daemon_proto_msgTypes[28].OneofWrappers = []any{ + file_daemon_proto_msgTypes[29].OneofWrappers = []any{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } - file_daemon_proto_msgTypes[47].OneofWrappers = []any{} file_daemon_proto_msgTypes[48].OneofWrappers = []any{} - file_daemon_proto_msgTypes[54].OneofWrappers = []any{} - file_daemon_proto_msgTypes[56].OneofWrappers = []any{} - file_daemon_proto_msgTypes[67].OneofWrappers = []any{} - file_daemon_proto_msgTypes[75].OneofWrappers = []any{} - file_daemon_proto_msgTypes[86].OneofWrappers = []any{ + file_daemon_proto_msgTypes[49].OneofWrappers = []any{} + file_daemon_proto_msgTypes[55].OneofWrappers = []any{} + file_daemon_proto_msgTypes[57].OneofWrappers = []any{} + file_daemon_proto_msgTypes[68].OneofWrappers = []any{} + file_daemon_proto_msgTypes[76].OneofWrappers = []any{} + file_daemon_proto_msgTypes[87].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7087,7 +7174,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 97, + NumMessages: 98, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index dedff43e2c9..2bc37b684bc 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -205,6 +205,8 @@ message LoginRequest { optional bool disableSSHAuth = 38; optional int32 sshJWTCacheTTL = 39; optional bool disable_ipv6 = 40; + + optional bool serverVNCAllowed = 41; } message LoginResponse { @@ -314,6 +316,8 @@ message GetConfigResponse { int32 sshJWTCacheTTL = 26; bool disable_ipv6 = 27; + + bool serverVNCAllowed = 28; } // PeerState contains the latest state of a peer @@ -394,6 +398,11 @@ message SSHServerState { repeated SSHSessionInfo sessions = 2; } +// VNCServerState contains the latest state of the VNC server +message VNCServerState { + bool enabled = 1; +} + // FullStatus contains the full state held by the Status instance message FullStatus { ManagementState managementState = 1; @@ -408,6 +417,7 @@ message FullStatus { bool lazyConnectionEnabled = 9; SSHServerState sshServerState = 10; + VNCServerState vncServerState = 11; } // Networks @@ -678,6 +688,8 @@ message SetConfigRequest { optional bool disableSSHAuth = 33; optional int32 sshJWTCacheTTL = 34; optional bool disable_ipv6 = 35; + + optional bool serverVNCAllowed = 36; } message SetConfigResponse{} diff --git a/client/server/server.go b/client/server/server.go index 397fb37e4c5..5465abfc23c 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -376,6 +376,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques config.RosenpassPermissive = msg.RosenpassPermissive config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed + config.ServerVNCAllowed = msg.ServerVNCAllowed config.NetworkMonitor = msg.NetworkMonitor config.DisableClientRoutes = msg.DisableClientRoutes config.DisableServerRoutes = msg.DisableServerRoutes @@ -1136,6 +1137,7 @@ func (s *Server) Status( pbFullStatus := fullStatus.ToProto() pbFullStatus.Events = s.statusRecorder.GetEventHistory() pbFullStatus.SshServerState = s.getSSHServerState() + pbFullStatus.VncServerState = s.getVNCServerState() statusResponse.FullStatus = pbFullStatus } @@ -1175,6 +1177,26 @@ func (s *Server) getSSHServerState() *proto.SSHServerState { return sshServerState } +// getVNCServerState retrieves the current VNC server state. +func (s *Server) getVNCServerState() *proto.VNCServerState { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return nil + } + + engine := connectClient.Engine() + if engine == nil { + return nil + } + + return &proto.VNCServerState{ + Enabled: engine.GetVNCServerStatus(), + } +} + // GetPeerSSHHostKey retrieves SSH host key for a specific peer func (s *Server) GetPeerSSHHostKey( ctx context.Context, @@ -1531,6 +1553,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p Mtu: int64(cfg.MTU), DisableAutoConnect: cfg.DisableAutoConnect, ServerSSHAllowed: *cfg.ServerSSHAllowed, + ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed, RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, LazyConnectionEnabled: cfg.LazyConnectionEnabled, diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 553d4ad7154..01dbbed5afc 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -58,6 +58,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { rosenpassEnabled := true rosenpassPermissive := true serverSSHAllowed := true + serverVNCAllowed := true interfaceName := "utun100" wireguardPort := int64(51820) preSharedKey := "test-psk" @@ -83,6 +84,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { RosenpassEnabled: &rosenpassEnabled, RosenpassPermissive: &rosenpassPermissive, ServerSSHAllowed: &serverSSHAllowed, + ServerVNCAllowed: &serverVNCAllowed, InterfaceName: &interfaceName, WireguardPort: &wireguardPort, OptionalPreSharedKey: &preSharedKey, @@ -127,6 +129,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive) require.NotNil(t, cfg.ServerSSHAllowed) require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed) + require.NotNil(t, cfg.ServerVNCAllowed) + require.Equal(t, serverVNCAllowed, *cfg.ServerVNCAllowed) require.Equal(t, interfaceName, cfg.WgIface) require.Equal(t, int(wireguardPort), cfg.WgPort) require.Equal(t, preSharedKey, cfg.PreSharedKey) @@ -179,6 +183,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "RosenpassEnabled": true, "RosenpassPermissive": true, "ServerSSHAllowed": true, + "ServerVNCAllowed": true, "InterfaceName": true, "WireguardPort": true, "OptionalPreSharedKey": true, @@ -240,6 +245,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-rosenpass": "RosenpassEnabled", "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", + "allow-server-vnc": "ServerVNCAllowed", "interface-name": "InterfaceName", "wireguard-port": "WireguardPort", "preshared-key": "OptionalPreSharedKey", diff --git a/client/ssh/server/executor_windows.go b/client/ssh/server/executor_windows.go index 51c995ec3cb..4053170d28f 100644 --- a/client/ssh/server/executor_windows.go +++ b/client/ssh/server/executor_windows.go @@ -200,8 +200,8 @@ func newLsaString(s string) lsaString { } } -// generateS4UUserToken creates a Windows token using S4U authentication -// This is the exact approach OpenSSH for Windows uses for public key authentication +// generateS4UUserToken creates a Windows token using S4U authentication. +// This is the same approach OpenSSH for Windows uses for public key authentication. func generateS4UUserToken(logger *log.Entry, username, domain string) (windows.Handle, error) { userCpn := buildUserCpn(username, domain) diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 6735e0f3bc0..a5f1effb160 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -551,27 +551,7 @@ func (s *Server) checkTokenAge(token *gojwt.Token, jwtConfig *JWTConfig) error { maxTokenAge = DefaultJWTMaxTokenAge } - claims, ok := token.Claims.(gojwt.MapClaims) - if !ok { - userID := extractUserID(token) - return fmt.Errorf("token has invalid claims format (user=%s)", userID) - } - - iat, ok := claims["iat"].(float64) - if !ok { - userID := extractUserID(token) - return fmt.Errorf("token missing iat claim (user=%s)", userID) - } - - issuedAt := time.Unix(int64(iat), 0) - tokenAge := time.Since(issuedAt) - maxAge := time.Duration(maxTokenAge) * time.Second - if tokenAge > maxAge { - userID := getUserIDFromClaims(claims) - return fmt.Errorf("token expired for user=%s: age=%v, max=%v", userID, tokenAge, maxAge) - } - - return nil + return jwt.CheckTokenAge(token, time.Duration(maxTokenAge)*time.Second) } func (s *Server) extractAndValidateUser(token *gojwt.Token) (*auth.UserAuth, error) { @@ -602,27 +582,7 @@ func (s *Server) hasSSHAccess(userAuth *auth.UserAuth) bool { } func extractUserID(token *gojwt.Token) string { - if token == nil { - return "unknown" - } - claims, ok := token.Claims.(gojwt.MapClaims) - if !ok { - return "unknown" - } - return getUserIDFromClaims(claims) -} - -func getUserIDFromClaims(claims gojwt.MapClaims) string { - if sub, ok := claims["sub"].(string); ok && sub != "" { - return sub - } - if userID, ok := claims["user_id"].(string); ok && userID != "" { - return userID - } - if email, ok := claims["email"].(string); ok && email != "" { - return email - } - return "unknown" + return jwt.UserIDFromToken(token) } func (s *Server) parseTokenWithoutValidation(tokenString string) (map[string]interface{}, error) { diff --git a/client/status/status.go b/client/status/status.go index 11ed06c2dc4..0ea89927f80 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -131,6 +131,10 @@ type SSHServerStateOutput struct { Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"` } +type VNCServerStateOutput struct { + Enabled bool `json:"enabled" yaml:"enabled"` +} + type OutputOverview struct { Peers PeersStateOutput `json:"peers" yaml:"peers"` CliVersion string `json:"cliVersion" yaml:"cliVersion"` @@ -153,6 +157,7 @@ type OutputOverview struct { LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` ProfileName string `json:"profileName" yaml:"profileName"` SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` + VNCServerState VNCServerStateOutput `json:"vncServer" yaml:"vncServer"` } // ConvertToStatusOutputOverview converts protobuf status to the output overview. @@ -173,6 +178,9 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO relayOverview := mapRelays(pbFullStatus.GetRelays()) sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState()) + vncServerOverview := VNCServerStateOutput{ + Enabled: pbFullStatus.GetVncServerState().GetEnabled(), + } peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter) overview := OutputOverview{ @@ -197,6 +205,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), ProfileName: opts.ProfileName, SSHServerState: sshServerOverview, + VNCServerState: vncServerOverview, } if opts.Anonymize { @@ -533,6 +542,11 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS } } + vncServerStatus := "Disabled" + if o.VNCServerState.Enabled { + vncServerStatus = "Enabled" + } + peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total) var forwardingRulesString string @@ -563,6 +577,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "Quantum resistance: %s\n"+ "Lazy connection: %s\n"+ "SSH Server: %s\n"+ + "VNC Server: %s\n"+ "Networks: %s\n"+ "%s"+ "Peers count: %s\n", @@ -581,6 +596,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS rosenpassEnabledStatus, lazyConnectionEnabledStatus, sshServerStatus, + vncServerStatus, networks, forwardingRulesString, peersCountString, diff --git a/client/status/status_test.go b/client/status/status_test.go index 0986bf0cd53..ee0fd27182b 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -404,6 +404,9 @@ func TestParsingToJSON(t *testing.T) { "sshServer":{ "enabled":false, "sessions":[] + }, + "vncServer":{ + "enabled":false } }` // @formatter:on @@ -513,6 +516,8 @@ profileName: "" sshServer: enabled: false sessions: [] +vncServer: + enabled: false ` assert.Equal(t, expectedYAML, yaml) @@ -582,6 +587,7 @@ Interface type: Kernel Quantum resistance: false Lazy connection: false SSH Server: Disabled +VNC Server: Disabled Networks: 10.10.0.0/24 Peers count: 2/2 Connected `, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion) @@ -607,6 +613,7 @@ Interface type: Kernel Quantum resistance: false Lazy connection: false SSH Server: Disabled +VNC Server: Disabled Networks: 10.10.0.0/24 Peers count: 2/2 Connected ` diff --git a/client/system/info.go b/client/system/info.go index 477d5162b1e..176d1131195 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -62,6 +62,7 @@ type Info struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed bool + ServerVNCAllowed bool DisableClientRoutes bool DisableServerRoutes bool @@ -83,6 +84,7 @@ type Info struct { func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, + serverVNCAllowed *bool, disableClientRoutes, disableServerRoutes, disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, @@ -93,6 +95,9 @@ func (i *Info) SetFlags( if serverSSHAllowed != nil { i.ServerSSHAllowed = *serverSSHAllowed } + if serverVNCAllowed != nil { + i.ServerVNCAllowed = *serverVNCAllowed + } i.DisableClientRoutes = disableClientRoutes i.DisableServerRoutes = disableServerRoutes diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index c2129c7a242..863e8b291c5 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -249,6 +249,7 @@ type serviceClient struct { mQuit *systray.MenuItem mNetworks *systray.MenuItem mAllowSSH *systray.MenuItem + mAllowVNC *systray.MenuItem mAutoConnect *systray.MenuItem mEnableRosenpass *systray.MenuItem mLazyConnEnabled *systray.MenuItem @@ -1045,6 +1046,7 @@ func (s *serviceClient) onTrayReady() { s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr) s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false) + s.mAllowVNC = s.mSettings.AddSubMenuItemCheckbox("Allow VNC", allowVNCMenuDescr, false) s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false) s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false) s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false) @@ -1452,6 +1454,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { config.DisableAutoConnect = cfg.DisableAutoConnect config.ServerSSHAllowed = &cfg.ServerSSHAllowed + config.ServerVNCAllowed = &cfg.ServerVNCAllowed config.RosenpassEnabled = cfg.RosenpassEnabled config.RosenpassPermissive = cfg.RosenpassPermissive config.DisableNotifications = &cfg.DisableNotifications @@ -1547,6 +1550,12 @@ func (s *serviceClient) loadSettings() { s.mAllowSSH.Uncheck() } + if cfg.ServerVNCAllowed { + s.mAllowVNC.Check() + } else { + s.mAllowVNC.Uncheck() + } + if cfg.DisableAutoConnect { s.mAutoConnect.Uncheck() } else { @@ -1586,6 +1595,7 @@ func (s *serviceClient) loadSettings() { func (s *serviceClient) updateConfig() error { disableAutoStart := !s.mAutoConnect.Checked() sshAllowed := s.mAllowSSH.Checked() + vncAllowed := s.mAllowVNC.Checked() rosenpassEnabled := s.mEnableRosenpass.Checked() lazyConnectionEnabled := s.mLazyConnEnabled.Checked() blockInbound := s.mBlockInbound.Checked() @@ -1614,6 +1624,7 @@ func (s *serviceClient) updateConfig() error { Username: currUser.Username, DisableAutoConnect: &disableAutoStart, ServerSSHAllowed: &sshAllowed, + ServerVNCAllowed: &vncAllowed, RosenpassEnabled: &rosenpassEnabled, LazyConnectionEnabled: &lazyConnectionEnabled, BlockInbound: &blockInbound, diff --git a/client/ui/const.go b/client/ui/const.go index 48619be752c..f666a0361d8 100644 --- a/client/ui/const.go +++ b/client/ui/const.go @@ -2,6 +2,7 @@ package main const ( allowSSHMenuDescr = "Allow SSH connections" + allowVNCMenuDescr = "Allow embedded VNC server" autoConnectMenuDescr = "Connect automatically when the service starts" quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass" lazyConnMenuDescr = "[Experimental] Enable lazy connections" diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go index 876fcef5fd8..a19cdf40c3e 100644 --- a/client/ui/event_handler.go +++ b/client/ui/event_handler.go @@ -39,6 +39,8 @@ func (h *eventHandler) listen(ctx context.Context) { h.handleDisconnectClick() case <-h.client.mAllowSSH.ClickedCh: h.handleAllowSSHClick() + case <-h.client.mAllowVNC.ClickedCh: + h.handleAllowVNCClick() case <-h.client.mAutoConnect.ClickedCh: h.handleAutoConnectClick() case <-h.client.mEnableRosenpass.ClickedCh: @@ -134,6 +136,15 @@ func (h *eventHandler) handleAllowSSHClick() { } +func (h *eventHandler) handleAllowVNCClick() { + h.toggleCheckbox(h.client.mAllowVNC) + if err := h.updateConfigWithErr(); err != nil { + h.toggleCheckbox(h.client.mAllowVNC) // revert checkbox state on error + log.Errorf("failed to update config: %v", err) + h.client.notifier.Send("Error", "Failed to update VNC settings") + } +} + func (h *eventHandler) handleAutoConnectClick() { h.toggleCheckbox(h.client.mAutoConnect) if err := h.updateConfigWithErr(); err != nil { diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go new file mode 100644 index 00000000000..a0d2790802b --- /dev/null +++ b/client/vnc/server/agent_windows.go @@ -0,0 +1,816 @@ +//go:build windows + +package server + +import ( + "bufio" + crand "crypto/rand" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "runtime" + "strconv" + "strings" + "sync" + "time" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + agentPort = "15900" + + // agentTokenLen is the length of the random authentication token + // used to verify that connections to the agent come from the service. + agentTokenLen = 32 + + stillActive = 259 + + tokenPrimary = 1 + securityImpersonation = 2 + tokenSessionID = 12 + + createUnicodeEnvironment = 0x00000400 + createNoWindow = 0x08000000 + createSuspended = 0x00000004 + createBreakawayFromJob = 0x01000000 +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + advapi32 = windows.NewLazySystemDLL("advapi32.dll") + userenv = windows.NewLazySystemDLL("userenv.dll") + + procWTSGetActiveConsoleSessionId = kernel32.NewProc("WTSGetActiveConsoleSessionId") + procCreateJobObjectW = kernel32.NewProc("CreateJobObjectW") + procSetInformationJobObject = kernel32.NewProc("SetInformationJobObject") + procAssignProcessToJobObject = kernel32.NewProc("AssignProcessToJobObject") + procSetTokenInformation = advapi32.NewProc("SetTokenInformation") + procCreateEnvironmentBlock = userenv.NewProc("CreateEnvironmentBlock") + procDestroyEnvironmentBlock = userenv.NewProc("DestroyEnvironmentBlock") + + wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll") + procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW") + procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory") + procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW") + + iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") + procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable") +) + +// GetCurrentSessionID returns the session ID of the current process. +func GetCurrentSessionID() uint32 { + var token windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), + windows.TOKEN_QUERY, &token); err != nil { + return 0 + } + defer token.Close() + var id uint32 + var ret uint32 + _ = windows.GetTokenInformation(token, windows.TokenSessionId, + (*byte)(unsafe.Pointer(&id)), 4, &ret) + return id +} + +func getConsoleSessionID() uint32 { + r, _, _ := procWTSGetActiveConsoleSessionId.Call() + return uint32(r) +} + +const ( + wtsActive = 0 + wtsConnected = 1 + wtsDisconnected = 4 +) + +type wtsSessionInfo struct { + SessionID uint32 + WinStationName [66]byte // actually *uint16, but we just need the struct size + State uint32 +} + +// getActiveSessionID returns the session ID of the best session to attach to. +// On a Windows Server with no console display attached, session 1 still +// reports WTSActive (login screen "owns" the console), so a naive +// first-active-wins pick lands on a session with no actual rendering. +// Preference order: +// 1. Active session with a user logged in (RDP user in session ≥2) +// 2. Active session without a user (console at login screen) +// 3. Console session ID +func getActiveSessionID() uint32 { + var sessionInfo uintptr + var count uint32 + + r, _, _ := procWTSEnumerateSessionsW.Call( + 0, // WTS_CURRENT_SERVER_HANDLE + 0, // reserved + 1, // version + uintptr(unsafe.Pointer(&sessionInfo)), + uintptr(unsafe.Pointer(&count)), + ) + if r == 0 || count == 0 { + return getConsoleSessionID() + } + defer func() { _, _, _ = procWTSFreeMemory.Call(sessionInfo) }() + + type wtsSession struct { + SessionID uint32 + Station *uint16 + State uint32 + } + sessions := unsafe.Slice((*wtsSession)(unsafe.Pointer(sessionInfo)), count) + + var withUser uint32 + var withUserFound bool + var anyActive uint32 + var anyActiveFound bool + for _, s := range sessions { + if s.SessionID == 0 { + continue + } + if s.State != wtsActive { + continue + } + if !anyActiveFound { + anyActive = s.SessionID + anyActiveFound = true + } + if !withUserFound && wtsSessionHasUser(s.SessionID) { + withUser = s.SessionID + withUserFound = true + } + } + if withUserFound { + return withUser + } + if anyActiveFound { + return anyActive + } + return getConsoleSessionID() +} + +// reapOrphanOnPort finds any process listening on 127.0.0.1:portStr and, +// if it's a netbird vnc-agent left over from a previous service instance, +// terminates it. Verified by image-name match so we never kill an +// unrelated process that happens to use the same port. +func reapOrphanOnPort(portStr string) { + port64, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return + } + port := uint16(port64) + pid := tcpListenerPID(port) + if pid == 0 || pid == uint32(windows.GetCurrentProcessId()) { + return + } + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid) + if err != nil { + log.Warnf("reap on port %d: open PID=%d: %v", port, pid, err) + return + } + defer windows.CloseHandle(h) + if !isOurAgentProcess(h) { + log.Warnf("reap on port %d: PID=%d is not a netbird vnc-agent, leaving it alone", port, pid) + return + } + if err := windows.TerminateProcess(h, 0); err != nil { + log.Warnf("reap on port %d: terminate PID=%d: %v", port, pid, err) + return + } + log.Infof("reaped orphan vnc-agent PID=%d holding port %d", pid, port) +} + +// isOurAgentProcess returns true if the given process handle points at a +// netbird.exe binary at the same path as the current process. We compare +// full paths (case-insensitive on Windows) so co-installed netbird binaries +// from a different install dir or unrelated apps named netbird.exe don't +// get killed. +func isOurAgentProcess(h windows.Handle) bool { + var size uint32 = windows.MAX_PATH + buf := make([]uint16, size) + if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil { + return false + } + target := strings.ToLower(windows.UTF16ToString(buf[:size])) + selfExe, err := os.Executable() + if err != nil { + return false + } + return target == strings.ToLower(selfExe) +} + +// tcpListenerPID returns the PID of the process listening on 127.0.0.1:port, +// or 0 if none. Uses GetExtendedTcpTable with TCP_TABLE_OWNER_PID_LISTENER. +func tcpListenerPID(port uint16) uint32 { + const tcpTableOwnerPidListener = 3 + const afInet = 2 + + // MIB_TCPROW_OWNER_PID layout: state(4) + localAddr(4) + localPort(4) + + // remoteAddr(4) + remotePort(4) + owningPid(4) = 24 bytes. + const rowSize = 24 + + var size uint32 + _, _, _ = procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0, afInet, tcpTableOwnerPidListener, 0) + if size == 0 { + return 0 + } + buf := make([]byte, size) + r, _, _ := procGetExtendedTcpTable.Call( + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&size)), + 0, afInet, tcpTableOwnerPidListener, 0, + ) + if r != 0 { + return 0 + } + count := binary.LittleEndian.Uint32(buf[:4]) + for i := uint32(0); i < count; i++ { + off := 4 + int(i)*rowSize + if off+rowSize > len(buf) { + break + } + // localPort is stored big-endian in the high 16 bits of a 32-bit field. + localPort := uint16(buf[off+8])<<8 | uint16(buf[off+9]) + if localPort != port { + continue + } + localAddr := binary.LittleEndian.Uint32(buf[off+4 : off+8]) + // 0x0100007f == 127.0.0.1 in network byte order on little-endian. + // We accept 0.0.0.0 too in case the orphan bound to all interfaces. + if localAddr != 0x0100007f && localAddr != 0 { + continue + } + return binary.LittleEndian.Uint32(buf[off+20 : off+24]) + } + return 0 +} + +// wtsSessionHasUser returns true if the session has a non-empty user name, +// i.e. someone is logged in (vs. the login/Welcome screen). The console +// session at the lock screen has WTSUserName == "". +const wtsUserName = 5 + +func wtsSessionHasUser(sessionID uint32) bool { + var buf uintptr + var bytesReturned uint32 + r, _, _ := procWTSQuerySessionInformation.Call( + 0, // WTS_CURRENT_SERVER_HANDLE + uintptr(sessionID), + uintptr(wtsUserName), + uintptr(unsafe.Pointer(&buf)), + uintptr(unsafe.Pointer(&bytesReturned)), + ) + if r == 0 || buf == 0 { + return false + } + defer func() { _, _, _ = procWTSFreeMemory.Call(buf) }() + // First UTF-16 code unit non-zero ⇒ non-empty username. + return *(*uint16)(unsafe.Pointer(buf)) != 0 +} + +// getSystemTokenForSession duplicates the current SYSTEM token and sets its +// session ID so the spawned process runs in the target session. Using a SYSTEM +// token gives access to both Default and Winlogon desktops plus UIPI bypass. +func getSystemTokenForSession(sessionID uint32) (windows.Token, error) { + var cur windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), + windows.MAXIMUM_ALLOWED, &cur); err != nil { + return 0, fmt.Errorf("OpenProcessToken: %w", err) + } + defer cur.Close() + + var dup windows.Token + if err := windows.DuplicateTokenEx(cur, windows.MAXIMUM_ALLOWED, nil, + securityImpersonation, tokenPrimary, &dup); err != nil { + return 0, fmt.Errorf("DuplicateTokenEx: %w", err) + } + + sid := sessionID + r, _, err := procSetTokenInformation.Call( + uintptr(dup), + uintptr(tokenSessionID), + uintptr(unsafe.Pointer(&sid)), + unsafe.Sizeof(sid), + ) + if r == 0 { + dup.Close() + return 0, fmt.Errorf("SetTokenInformation(SessionId=%d): %w", sessionID, err) + } + return dup, nil +} + +const agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" + +// injectEnvVar appends a KEY=VALUE entry to a Unicode environment block. +// The block is a sequence of null-terminated UTF-16 strings, terminated by +// an extra null. Returns the new []uint16 backing slice; the caller must +// hold the returned slice alive until CreateProcessAsUser completes. +func injectEnvVar(envBlock uintptr, key, value string) []uint16 { + entry := key + "=" + value + + // Walk the existing block to find its total length. + ptr := (*uint16)(unsafe.Pointer(envBlock)) + var totalChars int + for { + ch := *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(totalChars)*2)) + if ch == 0 { + // Check for double-null terminator. + next := *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(totalChars+1)*2)) + totalChars++ + if next == 0 { + // End of block (don't count the final null yet, we'll rebuild). + break + } + } else { + totalChars++ + } + } + + entryUTF16, _ := windows.UTF16FromString(entry) + // New block: existing entries + new entry (null-terminated) + final null. + newLen := totalChars + len(entryUTF16) + 1 + newBlock := make([]uint16, newLen) + // Copy existing entries (up to but not including the final null). + for i := range totalChars { + newBlock[i] = *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(i)*2)) + } + copy(newBlock[totalChars:], entryUTF16) + newBlock[newLen-1] = 0 // final null terminator + + return newBlock +} + +func spawnAgentInSession(sessionID uint32, port string, authToken string, jobHandle windows.Handle) (windows.Handle, error) { + token, err := getSystemTokenForSession(sessionID) + if err != nil { + return 0, fmt.Errorf("get SYSTEM token for session %d: %w", sessionID, err) + } + defer token.Close() + + var envBlock uintptr + r, _, e := procCreateEnvironmentBlock.Call( + uintptr(unsafe.Pointer(&envBlock)), + uintptr(token), + 0, + ) + if r == 0 { + // Without an environment block we cannot inject NB_VNC_AGENT_TOKEN; + // the agent would start unauthenticated. Abort instead of launching. + return 0, fmt.Errorf("CreateEnvironmentBlock: %w", e) + } + defer func() { _, _, _ = procDestroyEnvironmentBlock.Call(envBlock) }() + + // Inject the auth token into the environment block so it doesn't appear + // in the process command line (visible via tasklist/wmic). injectedBlock + // must stay alive until CreateProcessAsUser returns. + injectedBlock := injectEnvVar(envBlock, agentTokenEnvVar, authToken) + + exePath, err := os.Executable() + if err != nil { + return 0, fmt.Errorf("get executable path: %w", err) + } + + cmdLine := fmt.Sprintf(`"%s" vnc-agent --port %s`, exePath, port) + cmdLineW, err := windows.UTF16PtrFromString(cmdLine) + if err != nil { + return 0, fmt.Errorf("UTF16 cmdline: %w", err) + } + + // Create an inheritable pipe for the agent's stderr so we can relog + // its output in the service process. + var sa windows.SecurityAttributes + sa.Length = uint32(unsafe.Sizeof(sa)) + sa.InheritHandle = 1 + + var stderrRead, stderrWrite windows.Handle + if err := windows.CreatePipe(&stderrRead, &stderrWrite, &sa, 0); err != nil { + return 0, fmt.Errorf("create stderr pipe: %w", err) + } + // The read end must NOT be inherited by the child. + _ = windows.SetHandleInformation(stderrRead, windows.HANDLE_FLAG_INHERIT, 0) + + desktop, _ := windows.UTF16PtrFromString(`WinSta0\Default`) + si := windows.StartupInfo{ + Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), + Desktop: desktop, + Flags: windows.STARTF_USESHOWWINDOW | windows.STARTF_USESTDHANDLES, + ShowWindow: 0, + StdErr: stderrWrite, + StdOutput: stderrWrite, + } + var pi windows.ProcessInformation + + var envPtr *uint16 + if len(injectedBlock) > 0 { + envPtr = &injectedBlock[0] + } else if envBlock != 0 { + envPtr = (*uint16)(unsafe.Pointer(envBlock)) + } + + // CREATE_SUSPENDED so we can assign the process to our Job Object + // before it executes. Without this the agent could spawn its own child + // processes and have them inherit the SCM service-job (not ours), or + // briefly listen on the agent port before we tear it down on rollback. + // CREATE_BREAKAWAY_FROM_JOB lets the child leave the SCM-managed + // service job; harmless if that job allows breakaway, and is required + // before AssignProcessToJobObject can succeed in the no-nested-jobs case. + err = windows.CreateProcessAsUser( + token, nil, cmdLineW, + nil, nil, true, // inheritHandles=true for the pipe + createUnicodeEnvironment|createNoWindow|createSuspended|createBreakawayFromJob, + envPtr, nil, &si, &pi, + ) + runtime.KeepAlive(injectedBlock) + // Close the write end in the parent so reads will get EOF when the child exits. + _ = windows.CloseHandle(stderrWrite) + if err != nil { + _ = windows.CloseHandle(stderrRead) + return 0, fmt.Errorf("CreateProcessAsUser: %w", err) + } + + if jobHandle != 0 { + r, _, e := procAssignProcessToJobObject.Call(uintptr(jobHandle), uintptr(pi.Process)) + if r == 0 { + log.Warnf("assign agent to job object: %v (orphan possible on service crash)", e) + } + } + + if _, err := windows.ResumeThread(pi.Thread); err != nil { + log.Warnf("resume agent main thread: %v", err) + } + _ = windows.CloseHandle(pi.Thread) + + // Relog agent output in the service with a [vnc-agent] prefix. + go relogAgentOutput(stderrRead) + + log.Infof("spawned agent PID=%d in session %d on port %s", pi.ProcessId, sessionID, port) + return pi.Process, nil +} + +// sessionManager monitors the active console session and ensures a VNC agent +// process is running in it. When the session changes (e.g., user switch, RDP +// connect/disconnect), it kills the old agent and spawns a new one. +type sessionManager struct { + port string + mu sync.Mutex + agentProc windows.Handle + everSpawned bool + agentStartedAt time.Time + spawnFailures int + nextSpawnAt time.Time + sessionID uint32 + authToken string + done chan struct{} + // jobHandle owns the agent processes via a Windows Job Object with + // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the service exits or crashes, + // the OS closes the handle and terminates every assigned agent: no + // orphaned listeners holding the agent port across restarts. + jobHandle windows.Handle +} + +func newSessionManager(port string) *sessionManager { + m := &sessionManager{port: port, sessionID: ^uint32(0), done: make(chan struct{})} + if h, err := createKillOnCloseJob(); err != nil { + log.Warnf("create job object for vnc-agent (orphan agents possible after crash): %v", err) + } else { + m.jobHandle = h + } + return m +} + +// createKillOnCloseJob returns a Job Object configured so that closing its +// handle (process exit or explicit Close) terminates every process assigned +// to it. Used to keep orphaned vnc-agent processes from outliving the service. +func createKillOnCloseJob() (windows.Handle, error) { + r, _, e := procCreateJobObjectW.Call(0, 0) + if r == 0 { + return 0, fmt.Errorf("CreateJobObject: %w", e) + } + job := windows.Handle(r) + + // JOBOBJECT_EXTENDED_LIMIT_INFORMATION on amd64 = 144 bytes. + // + // JOBOBJECT_BASIC_LIMIT_INFORMATION (64 bytes with alignment padding) + // PerProcessUserTimeLimit LARGE_INTEGER off 0 + // PerJobUserTimeLimit LARGE_INTEGER off 8 + // LimitFlags DWORD off 16 + // [4 byte pad to align SIZE_T] + // MinimumWorkingSetSize SIZE_T off 24 + // MaximumWorkingSetSize SIZE_T off 32 + // ActiveProcessLimit DWORD off 40 + // [4 byte pad to align ULONG_PTR] + // Affinity ULONG_PTR off 48 + // PriorityClass DWORD off 56 + // SchedulingClass DWORD off 60 + // IO_COUNTERS (48) + 4 * SIZE_T (32) = 144 total. + // + // We only set LimitFlags; the rest stays zero. + const sizeofExtended = 144 + const offsetLimitFlags = 16 + const jobObjectExtendedLimitInformation = 9 + const jobObjectLimitKillOnJobClose = 0x00002000 + + var info [sizeofExtended]byte + binary.LittleEndian.PutUint32(info[offsetLimitFlags:offsetLimitFlags+4], jobObjectLimitKillOnJobClose) + + r, _, e = procSetInformationJobObject.Call( + uintptr(job), + uintptr(jobObjectExtendedLimitInformation), + uintptr(unsafe.Pointer(&info[0])), + uintptr(sizeofExtended), + ) + if r == 0 { + _ = windows.CloseHandle(job) + return 0, fmt.Errorf("SetInformationJobObject(KILL_ON_JOB_CLOSE): %w", e) + } + return job, nil +} + +// generateAuthToken creates a new random hex token for agent authentication. +func generateAuthToken() string { + b := make([]byte, agentTokenLen) + if _, err := crand.Read(b); err != nil { + log.Warnf("generate agent auth token: %v", err) + return "" + } + return hex.EncodeToString(b) +} + +// AuthToken returns the current agent authentication token. +func (m *sessionManager) AuthToken() string { + m.mu.Lock() + defer m.mu.Unlock() + return m.authToken +} + +// Stop signals the session manager to exit its polling loop and closes the +// Job Object handle, which Windows uses as the trigger to terminate every +// agent process this manager spawned. +func (m *sessionManager) Stop() { + select { + case <-m.done: + default: + close(m.done) + } + m.mu.Lock() + if m.jobHandle != 0 { + _ = windows.CloseHandle(m.jobHandle) + m.jobHandle = 0 + } + m.mu.Unlock() +} + +func (m *sessionManager) run() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + if !m.tick() { + return + } + select { + case <-m.done: + m.mu.Lock() + m.killAgent() + m.mu.Unlock() + return + case <-ticker.C: + } + } +} + +// tick performs one session/agent-state update. Returns false if the manager +// should permanently stop (e.g. missing SYSTEM privileges). +func (m *sessionManager) tick() bool { + sid := getActiveSessionID() + + m.mu.Lock() + defer m.mu.Unlock() + + m.handleSessionChange(sid) + m.reapExitedAgent() + return m.maybeSpawnAgent(sid) +} + +func (m *sessionManager) handleSessionChange(sid uint32) { + if sid == m.sessionID { + return + } + log.Infof("active session changed: %d -> %d", m.sessionID, sid) + m.killAgent() + m.sessionID = sid +} + +func (m *sessionManager) reapExitedAgent() { + if m.agentProc == 0 { + return + } + var code uint32 + if err := windows.GetExitCodeProcess(m.agentProc, &code); err != nil { + log.Debugf("GetExitCodeProcess: %v", err) + return + } + if code == stillActive { + return + } + m.scheduleNextSpawn(code, time.Since(m.agentStartedAt)) + if err := windows.CloseHandle(m.agentProc); err != nil { + log.Debugf("close agent handle: %v", err) + } + m.agentProc = 0 +} + +// scheduleNextSpawn applies an exponential backoff on fast crashes (<5s) and +// resets immediately otherwise. +func (m *sessionManager) scheduleNextSpawn(exitCode uint32, lifetime time.Duration) { + if lifetime < 5*time.Second { + m.spawnFailures++ + backoff := time.Duration(1< 30*time.Second { + backoff = 30 * time.Second + } + m.nextSpawnAt = time.Now().Add(backoff) + log.Warnf("agent exited (code=%d) after %v, retrying in %v (failures=%d)", exitCode, lifetime.Round(time.Millisecond), backoff, m.spawnFailures) + return + } + m.spawnFailures = 0 + m.nextSpawnAt = time.Time{} + log.Infof("agent exited (code=%d) after %v, respawning", exitCode, lifetime.Round(time.Second)) +} + +// maybeSpawnAgent spawns a new agent if there's no current one and the backoff +// window has elapsed. Returns false to permanently stop the manager when the +// service lacks the privileges needed to spawn cross-session. +func (m *sessionManager) maybeSpawnAgent(sid uint32) bool { + if m.agentProc != 0 || sid == 0xFFFFFFFF || !time.Now().After(m.nextSpawnAt) { + return true + } + // Reap any orphan still holding the agent port from a previous + // service instance, only on our very first spawn. Once we own + // an agent, we manage its lifecycle ourselves and never need to + // kill an unknown listener; if a kill+respawn races on port + // release, the spawn-failure backoff handles it without forcing + // a synchronous wait or duplicate kill. + if !m.everSpawned { + reapOrphanOnPort(m.port) + } + m.authToken = generateAuthToken() + h, err := spawnAgentInSession(sid, m.port, m.authToken, m.jobHandle) + if err != nil { + m.authToken = "" + if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) { + // SE_TCB_NAME (token-impersonation across sessions) is only + // granted to SYSTEM. Without it spawnAgent will fail every 2 + // seconds forever: log once and give up. + log.Warnf("VNC service mode disabled: agent spawn requires SYSTEM privileges (got: %v)", err) + return false + } + log.Warnf("spawn agent in session %d: %v", sid, err) + return true + } + m.agentProc = h + m.agentStartedAt = time.Now() + m.everSpawned = true + return true +} + +func (m *sessionManager) killAgent() { + if m.agentProc == 0 { + return + } + _ = windows.TerminateProcess(m.agentProc, 0) + _ = windows.CloseHandle(m.agentProc) + m.agentProc = 0 + log.Info("killed old agent") +} + +// relogAgentOutput reads log lines from the agent's stderr pipe and +// relogs them with the service's formatter. Each line is tried as JSON +// first (the agent's normal log format); plain-text lines (e.g. cobra +// error output, panic stack traces) are forwarded verbatim so failures +// during early agent startup remain visible. +func relogAgentOutput(pipe windows.Handle) { + defer windows.CloseHandle(pipe) + f := os.NewFile(uintptr(pipe), "vnc-agent-stderr") + defer f.Close() + + entry := log.WithField("component", "vnc-agent") + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + if line[0] != '{' { + entry.Warn(string(line)) + continue + } + var m map[string]any + if err := json.Unmarshal(line, &m); err != nil { + entry.Warn(string(line)) + continue + } + msg, _ := m["msg"].(string) + if msg == "" { + continue + } + + fields := make(log.Fields) + for k, v := range m { + switch k { + case "msg", "level", "time", "func": + continue + case "caller": + fields["source"] = v + default: + fields[k] = v + } + } + e := entry.WithFields(fields) + + switch m["level"] { + case "error": + e.Error(msg) + case "warning": + e.Warn(msg) + case "debug": + e.Debug(msg) + case "trace": + e.Trace(msg) + default: + e.Info(msg) + } + } +} + +// proxyToAgent connects to the agent, sends the auth token, then proxies +// the VNC client connection bidirectionally. +func proxyToAgent(client net.Conn, port string, authToken string) { + defer client.Close() + + addr := "127.0.0.1:" + port + var agentConn net.Conn + var err error + for range 50 { + agentConn, err = net.DialTimeout("tcp", addr, time.Second) + if err == nil { + break + } + time.Sleep(200 * time.Millisecond) + } + if err != nil { + log.Warnf("proxy cannot reach agent at %s: %v", addr, err) + return + } + defer agentConn.Close() + + // Send the auth token so the agent can verify this connection + // comes from the trusted service process. + tokenBytes, _ := hex.DecodeString(authToken) + if _, err := agentConn.Write(tokenBytes); err != nil { + log.Warnf("send auth token to agent: %v", err) + return + } + + log.Debugf("proxy connected to agent, starting bidirectional copy") + + done := make(chan struct{}, 2) + cp := func(label string, dst, src net.Conn) { + n, err := io.Copy(dst, src) + log.Debugf("proxy %s: %d bytes, err=%v", label, n, err) + done <- struct{}{} + } + go cp("client→agent", agentConn, client) + go cp("agent→client", client, agentConn) + <-done +} + +// logCleanupCall invokes a Windows syscall used solely as a cleanup primitive +// (CloseClipboard, ReleaseDC, etc.) and logs failures at trace level. The +// indirection lets us satisfy errcheck without scattering ignored returns at +// each call site, while still capturing diagnostic info when the OS reports +// a failure. +func logCleanupCall(name string, proc *windows.LazyProc) { + r, _, err := proc.Call() + if r == 0 && err != nil && err != windows.NTE_OP_OK { + log.Tracef("%s: %v", name, err) + } +} + +// logCleanupCallArgs is logCleanupCall with one argument; common pattern for +// release-by-handle syscalls. +func logCleanupCallArgs(name string, proc *windows.LazyProc, args ...uintptr) { + r, _, err := proc.Call(args...) + if r == 0 && err != nil && err != windows.NTE_OP_OK { + log.Tracef("%s: %v", name, err) + } +} diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go new file mode 100644 index 00000000000..04db7f081d0 --- /dev/null +++ b/client/vnc/server/capture_darwin.go @@ -0,0 +1,597 @@ +//go:build darwin && !ios + +package server + +import ( + "errors" + "fmt" + "hash/maphash" + "image" + "os" + "runtime" + "strconv" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/ebitengine/purego" + log "github.com/sirupsen/logrus" +) + +var darwinCaptureOnce sync.Once + +var ( + cgMainDisplayID func() uint32 + cgDisplayPixelsWide func(uint32) uintptr + cgDisplayPixelsHigh func(uint32) uintptr + cgDisplayCreateImage func(uint32) uintptr + cgImageGetWidth func(uintptr) uintptr + cgImageGetHeight func(uintptr) uintptr + cgImageGetBytesPerRow func(uintptr) uintptr + cgImageGetBitsPerPixel func(uintptr) uintptr + cgImageGetDataProvider func(uintptr) uintptr + cgDataProviderCopyData func(uintptr) uintptr + cgImageRelease func(uintptr) + cfDataGetLength func(uintptr) int64 + cfDataGetBytePtr func(uintptr) uintptr + cfRelease func(uintptr) + cgPreflightScreenCaptureAccess func() bool + cgRequestScreenCaptureAccess func() bool + darwinCaptureReady bool +) + +func initDarwinCapture() { + darwinCaptureOnce.Do(func() { + cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + log.Debugf("load CoreGraphics: %v", err) + return + } + cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + log.Debugf("load CoreFoundation: %v", err) + return + } + + purego.RegisterLibFunc(&cgMainDisplayID, cg, "CGMainDisplayID") + purego.RegisterLibFunc(&cgDisplayPixelsWide, cg, "CGDisplayPixelsWide") + purego.RegisterLibFunc(&cgDisplayPixelsHigh, cg, "CGDisplayPixelsHigh") + purego.RegisterLibFunc(&cgDisplayCreateImage, cg, "CGDisplayCreateImage") + purego.RegisterLibFunc(&cgImageGetWidth, cg, "CGImageGetWidth") + purego.RegisterLibFunc(&cgImageGetHeight, cg, "CGImageGetHeight") + purego.RegisterLibFunc(&cgImageGetBytesPerRow, cg, "CGImageGetBytesPerRow") + purego.RegisterLibFunc(&cgImageGetBitsPerPixel, cg, "CGImageGetBitsPerPixel") + purego.RegisterLibFunc(&cgImageGetDataProvider, cg, "CGImageGetDataProvider") + purego.RegisterLibFunc(&cgDataProviderCopyData, cg, "CGDataProviderCopyData") + purego.RegisterLibFunc(&cgImageRelease, cg, "CGImageRelease") + purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength") + purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr") + purego.RegisterLibFunc(&cfRelease, cf, "CFRelease") + + // Screen capture permission APIs (macOS 11+). Might not exist on older versions. + if sym, err := purego.Dlsym(cg, "CGPreflightScreenCaptureAccess"); err == nil { + purego.RegisterFunc(&cgPreflightScreenCaptureAccess, sym) + } + if sym, err := purego.Dlsym(cg, "CGRequestScreenCaptureAccess"); err == nil { + purego.RegisterFunc(&cgRequestScreenCaptureAccess, sym) + } + + darwinCaptureReady = true + }) +} + + +// CGCapturer captures the macOS main display using Core Graphics. +type CGCapturer struct { + displayID uint32 + w, h int + // downscale is 1 for pixel-perfect, 2 for Retina 2:1 box-filter downscale. + downscale int + hashSeed maphash.Seed + lastHash uint64 + hasHash bool +} + +// NewCGCapturer creates a screen capturer for the main display. +func NewCGCapturer() (*CGCapturer, error) { + initDarwinCapture() + if !darwinCaptureReady { + return nil, fmt.Errorf("CoreGraphics not available") + } + + // Request Screen Recording permission (shows system dialog on macOS 11+). + if cgPreflightScreenCaptureAccess != nil && !cgPreflightScreenCaptureAccess() { + if cgRequestScreenCaptureAccess != nil { + cgRequestScreenCaptureAccess() + } + openPrivacyPane("Privacy_ScreenCapture") + log.Warn("Screen Recording permission not granted. " + + "Opened System Settings > Privacy & Security > Screen Recording; enable netbird and restart.") + } + + displayID := cgMainDisplayID() + c := &CGCapturer{displayID: displayID, downscale: 1, hashSeed: maphash.MakeSeed()} + + // Probe actual pixel dimensions via a test capture. CGDisplayPixelsWide/High + // returns logical points on Retina, but CGDisplayCreateImage produces native + // pixels (often 2x), so probing the image is the only reliable source. + img, err := c.Capture() + if err != nil { + return nil, fmt.Errorf("probe capture: %w", err) + } + nativeW := img.Rect.Dx() + nativeH := img.Rect.Dy() + c.hasHash = false + if nativeW == 0 || nativeH == 0 { + return nil, errors.New("display dimensions are zero") + } + + logicalW := int(cgDisplayPixelsWide(displayID)) + logicalH := int(cgDisplayPixelsHigh(displayID)) + + // Enable 2:1 downscale on Retina unless explicitly disabled. Cuts pixel + // count 4x, shrinking convert, diff, and wire data proportionally. + if !retinaDownscaleDisabled() && nativeW >= 2*logicalW && nativeH >= 2*logicalH && nativeW%2 == 0 && nativeH%2 == 0 { + c.downscale = 2 + } + c.w = nativeW / c.downscale + c.h = nativeH / c.downscale + + log.Infof("macOS capturer ready: %dx%d (native %dx%d, logical %dx%d, downscale=%d, display=%d)", + c.w, c.h, nativeW, nativeH, logicalW, logicalH, c.downscale, displayID) + return c, nil +} + +func retinaDownscaleDisabled() bool { + v := os.Getenv(EnvVNCDisableDownscale) + if v == "" { + return false + } + disabled, err := strconv.ParseBool(v) + if err != nil { + log.Warnf("parse %s: %v", EnvVNCDisableDownscale, err) + return false + } + return disabled +} + +// Width returns the screen width. +func (c *CGCapturer) Width() int { return c.w } + +// Height returns the screen height. +func (c *CGCapturer) Height() int { return c.h } + +// Capture returns the current screen as an RGBA image. +// CaptureInto writes a fresh frame directly into dst, skipping the +// per-frame image.RGBA allocation that Capture() does. Returns +// errFrameUnchanged when the screen hash matches the prior call. +func (c *CGCapturer) CaptureInto(dst *image.RGBA) error { + cgImage := cgDisplayCreateImage(c.displayID) + if cgImage == 0 { + return fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)") + } + defer cgImageRelease(cgImage) + w := int(cgImageGetWidth(cgImage)) + h := int(cgImageGetHeight(cgImage)) + bytesPerRow := int(cgImageGetBytesPerRow(cgImage)) + bpp := int(cgImageGetBitsPerPixel(cgImage)) + provider := cgImageGetDataProvider(cgImage) + if provider == 0 { + return fmt.Errorf("CGImageGetDataProvider returned nil") + } + cfData := cgDataProviderCopyData(provider) + if cfData == 0 { + return fmt.Errorf("CGDataProviderCopyData returned nil") + } + defer cfRelease(cfData) + dataLen := int(cfDataGetLength(cfData)) + dataPtr := cfDataGetBytePtr(cfData) + if dataPtr == 0 || dataLen == 0 { + return fmt.Errorf("empty image data") + } + src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen) + hash := maphash.Bytes(c.hashSeed, src) + if c.hasHash && hash == c.lastHash { + return errFrameUnchanged + } + c.lastHash = hash + c.hasHash = true + + ds := c.downscale + if ds < 1 { + ds = 1 + } + outW := w / ds + outH := h / ds + if dst.Rect.Dx() != outW || dst.Rect.Dy() != outH { + return fmt.Errorf("dst size mismatch: dst=%dx%d capturer=%dx%d", + dst.Rect.Dx(), dst.Rect.Dy(), outW, outH) + } + bytesPerPixel := bpp / 8 + if bytesPerPixel == 4 && ds == 1 { + convertBGRAToRGBA(dst.Pix, dst.Stride, src, bytesPerRow, w, h) + return nil + } + if bytesPerPixel == 4 && ds == 2 { + convertBGRAToRGBADownscale2(dst.Pix, dst.Stride, src, bytesPerRow, outW, outH) + return nil + } + for row := 0; row < outH; row++ { + srcOff := row * ds * bytesPerRow + dstOff := row * dst.Stride + for col := 0; col < outW; col++ { + si := srcOff + col*ds*bytesPerPixel + di := dstOff + col*4 + dst.Pix[di+0] = src[si+2] + dst.Pix[di+1] = src[si+1] + dst.Pix[di+2] = src[si+0] + dst.Pix[di+3] = 0xff + } + } + return nil +} + +func (c *CGCapturer) Capture() (*image.RGBA, error) { + cgImage := cgDisplayCreateImage(c.displayID) + if cgImage == 0 { + return nil, fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)") + } + defer cgImageRelease(cgImage) + + w := int(cgImageGetWidth(cgImage)) + h := int(cgImageGetHeight(cgImage)) + bytesPerRow := int(cgImageGetBytesPerRow(cgImage)) + bpp := int(cgImageGetBitsPerPixel(cgImage)) + + provider := cgImageGetDataProvider(cgImage) + if provider == 0 { + return nil, fmt.Errorf("CGImageGetDataProvider returned nil") + } + + cfData := cgDataProviderCopyData(provider) + if cfData == 0 { + return nil, fmt.Errorf("CGDataProviderCopyData returned nil") + } + defer cfRelease(cfData) + + dataLen := int(cfDataGetLength(cfData)) + dataPtr := cfDataGetBytePtr(cfData) + if dataPtr == 0 || dataLen == 0 { + return nil, fmt.Errorf("empty image data") + } + + src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen) + + hash := maphash.Bytes(c.hashSeed, src) + if c.hasHash && hash == c.lastHash { + return nil, errFrameUnchanged + } + c.lastHash = hash + c.hasHash = true + + ds := c.downscale + if ds < 1 { + ds = 1 + } + outW := w / ds + outH := h / ds + img := image.NewRGBA(image.Rect(0, 0, outW, outH)) + + bytesPerPixel := bpp / 8 + switch { + case bytesPerPixel == 4 && ds == 1: + convertBGRAToRGBA(img.Pix, img.Stride, src, bytesPerRow, w, h) + case bytesPerPixel == 4 && ds == 2: + convertBGRAToRGBADownscale2(img.Pix, img.Stride, src, bytesPerRow, outW, outH) + default: + convertBGRAToRGBAGeneric(img.Pix, img.Stride, src, bytesPerRow, outW, outH, bytesPerPixel, ds) + } + + return img, nil +} + +// convertBGRAToRGBAGeneric is the slow per-pixel fallback for non-4-bytes +// or non-1/2 downscale formats. Always available regardless of the source +// format quirks the fast paths optimize for. +func convertBGRAToRGBAGeneric(dst []byte, dstStride int, src []byte, srcStride, outW, outH, bytesPerPixel, ds int) { + for row := 0; row < outH; row++ { + srcOff := row * ds * srcStride + dstOff := row * dstStride + for col := 0; col < outW; col++ { + si := srcOff + col*ds*bytesPerPixel + di := dstOff + col*4 + dst[di+0] = src[si+2] + dst[di+1] = src[si+1] + dst[di+2] = src[si+0] + dst[di+3] = 0xff + } + } +} + +// convertBGRAToRGBADownscale2 averages every 2x2 BGRA block into one RGBA +// output pixel, parallelised across GOMAXPROCS cores. outW and outH are the +// destination dimensions (source is 2*outW by 2*outH). +func convertBGRAToRGBADownscale2(dst []byte, dstStride int, src []byte, srcStride, outW, outH int) { + workers := runtime.GOMAXPROCS(0) + if workers > outH { + workers = outH + } + if workers < 1 || outH < 32 { + workers = 1 + } + + convertRows := func(y0, y1 int) { + for row := y0; row < y1; row++ { + srcRow0 := 2 * row * srcStride + srcRow1 := srcRow0 + srcStride + dstOff := row * dstStride + for col := 0; col < outW; col++ { + s0 := srcRow0 + col*8 + s1 := srcRow1 + col*8 + b := (uint32(src[s0]) + uint32(src[s0+4]) + uint32(src[s1]) + uint32(src[s1+4])) >> 2 + g := (uint32(src[s0+1]) + uint32(src[s0+5]) + uint32(src[s1+1]) + uint32(src[s1+5])) >> 2 + r := (uint32(src[s0+2]) + uint32(src[s0+6]) + uint32(src[s1+2]) + uint32(src[s1+6])) >> 2 + di := dstOff + col*4 + dst[di+0] = byte(r) + dst[di+1] = byte(g) + dst[di+2] = byte(b) + dst[di+3] = 0xff + } + } + } + + if workers == 1 { + convertRows(0, outH) + return + } + + var wg sync.WaitGroup + chunk := (outH + workers - 1) / workers + for i := 0; i < workers; i++ { + y0 := i * chunk + y1 := y0 + chunk + if y1 > outH { + y1 = outH + } + if y0 >= y1 { + break + } + wg.Add(1) + go func(y0, y1 int) { + defer wg.Done() + convertRows(y0, y1) + }(y0, y1) + } + wg.Wait() +} + +// convertBGRAToRGBA swaps R/B channels using uint32 word operations, and +// parallelises across GOMAXPROCS cores for large images. +func convertBGRAToRGBA(dst []byte, dstStride int, src []byte, srcStride, w, h int) { + workers := runtime.GOMAXPROCS(0) + if workers > h { + workers = h + } + if workers < 1 || h < 64 { + workers = 1 + } + + convertRows := func(y0, y1 int) { + rowBytes := w * 4 + for row := y0; row < y1; row++ { + dstRow := dst[row*dstStride : row*dstStride+rowBytes] + srcRow := src[row*srcStride : row*srcStride+rowBytes] + dstU := unsafe.Slice((*uint32)(unsafe.Pointer(&dstRow[0])), w) + srcU := unsafe.Slice((*uint32)(unsafe.Pointer(&srcRow[0])), w) + for i, p := range srcU { + dstU[i] = (p & 0xff00ff00) | ((p & 0x000000ff) << 16) | ((p & 0x00ff0000) >> 16) | 0xff000000 + } + } + } + + if workers == 1 { + convertRows(0, h) + return + } + + var wg sync.WaitGroup + chunk := (h + workers - 1) / workers + for i := 0; i < workers; i++ { + y0 := i * chunk + y1 := y0 + chunk + if y1 > h { + y1 = h + } + if y0 >= y1 { + break + } + wg.Add(1) + go func(y0, y1 int) { + defer wg.Done() + convertRows(y0, y1) + }(y0, y1) + } + wg.Wait() +} + +// MacPoller wraps CGCapturer with a staleness-cached on-demand Capture: +// sessions drive captures themselves from their encoder goroutine, so we +// don't need a background ticker. The last result is cached for a short +// window so concurrent sessions coalesce into one capture. +// +// The capturer is allocated lazily on first use and released when all +// clients disconnect. Init is retried with backoff because the user may +// grant Screen Recording permission while the server is already running. +type MacPoller struct { + mu sync.Mutex + + capturer *CGCapturer + w, h int + + lastFrame *image.RGBA + lastAt time.Time + + clients atomic.Int32 + initFails int + initBackoffUntil time.Time + closed bool +} + +// macInitRetryBackoffFor returns the delay we wait between init attempts +// after consecutive failures. Screen Recording permission is a one-shot +// user grant, so after several failures we back off aggressively. +func macInitRetryBackoffFor(fails int) time.Duration { + switch { + case fails > 15: + return 30 * time.Second + case fails > 5: + return 10 * time.Second + default: + return 2 * time.Second + } +} + +// NewMacPoller creates a lazy on-demand capturer for the macOS display. +func NewMacPoller() *MacPoller { + return &MacPoller{} +} + +// Wake is a no-op retained for API compatibility. With on-demand capture +// there is no background retry loop to kick: init happens on the next +// Capture/ClientConnect call. +func (p *MacPoller) Wake() { + // intentional no-op +} + +// ClientConnect increments the active client count and eagerly initialises +// the capturer so the first FBUpdateRequest doesn't pay the init cost. +func (p *MacPoller) ClientConnect() { + if p.clients.Add(1) == 1 { + p.mu.Lock() + _ = p.ensureCapturerLocked() + p.mu.Unlock() + } +} + +// ClientDisconnect decrements the active client count. On the last +// disconnect the capturer is released. +func (p *MacPoller) ClientDisconnect() { + if p.clients.Add(-1) == 0 { + p.mu.Lock() + p.capturer = nil + p.lastFrame = nil + p.mu.Unlock() + } +} + +// Close releases all resources. +func (p *MacPoller) Close() { + p.mu.Lock() + p.closed = true + p.capturer = nil + p.lastFrame = nil + p.mu.Unlock() +} + +// Width returns the screen width. Triggers lazy init if needed. +func (p *MacPoller) Width() int { + p.mu.Lock() + defer p.mu.Unlock() + _ = p.ensureCapturerLocked() + return p.w +} + +// Height returns the screen height. Triggers lazy init if needed. +func (p *MacPoller) Height() int { + p.mu.Lock() + defer p.mu.Unlock() + _ = p.ensureCapturerLocked() + return p.h +} + +// CaptureInto fills dst directly via the underlying capturer, bypassing +// the freshness cache. +func (p *MacPoller) CaptureInto(dst *image.RGBA) error { + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.ensureCapturerLocked(); err != nil { + return err + } + err := p.capturer.CaptureInto(dst) + if errors.Is(err, errFrameUnchanged) { + // Caller (session) treats this as "no change"; the dst buffer + // keeps its prior contents from the previous capture cycle so + // the diff stays meaningful. + return err + } + if err != nil { + p.capturer = nil + return fmt.Errorf("macos capture: %w", err) + } + return nil +} + +// Capture returns a fresh frame, serving from the short-lived cache if a +// previous caller captured within freshWindow. Handles the +// errFrameUnchanged return from CGCapturer by reusing the cached frame. +func (p *MacPoller) Capture() (*image.RGBA, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.lastFrame != nil && time.Since(p.lastAt) < freshWindow { + return p.lastFrame, nil + } + if err := p.ensureCapturerLocked(); err != nil { + return nil, err + } + img, err := p.capturer.Capture() + if errors.Is(err, errFrameUnchanged) { + if p.lastFrame != nil { + p.lastAt = time.Now() + return p.lastFrame, nil + } + return nil, err + } + if err != nil { + // Drop the capturer so the next call retries init; the display stream + // can die if the session changes or permissions are revoked. + p.capturer = nil + return nil, fmt.Errorf("macos capture: %w", err) + } + p.lastFrame = img + p.lastAt = time.Now() + return img, nil +} + +// ensureCapturerLocked initialises the underlying CGCapturer if needed. +// Caller must hold p.mu. +func (p *MacPoller) ensureCapturerLocked() error { + if p.closed { + return fmt.Errorf("poller closed") + } + if p.capturer != nil { + return nil + } + if time.Now().Before(p.initBackoffUntil) { + return fmt.Errorf("macOS capturer unavailable (retry scheduled)") + } + c, err := NewCGCapturer() + if err != nil { + p.initFails++ + p.initBackoffUntil = time.Now().Add(macInitRetryBackoffFor(p.initFails)) + if p.initFails == 1 || p.initFails%10 == 0 { + log.Warnf("macOS capturer: %v (attempt %d)", err, p.initFails) + } else { + log.Debugf("macOS capturer: %v (attempt %d)", err, p.initFails) + } + return err + } + p.initFails = 0 + p.capturer = c + p.w, p.h = c.Width(), c.Height() + return nil +} + +var _ ScreenCapturer = (*MacPoller)(nil) diff --git a/client/vnc/server/capture_dxgi_windows.go b/client/vnc/server/capture_dxgi_windows.go new file mode 100644 index 00000000000..f64ec7158b3 --- /dev/null +++ b/client/vnc/server/capture_dxgi_windows.go @@ -0,0 +1,99 @@ +//go:build windows + +package server + +import ( + "errors" + "fmt" + "image" + + "github.com/kirides/go-d3d/d3d11" + "github.com/kirides/go-d3d/outputduplication" +) + +// dxgiCapturer captures the desktop using DXGI Desktop Duplication. +// Provides GPU-accelerated capture with native dirty rect tracking. +// Only works from the interactive user session, not Session 0. +// +// Uses a double-buffer: DXGI writes into img, then we copy to the current +// output buffer and hand it out. Alternating between two output buffers +// avoids allocating a new image.RGBA per frame (~8MB at 1080p, 30fps). +type dxgiCapturer struct { + dup *outputduplication.OutputDuplicator + device *d3d11.ID3D11Device + ctx *d3d11.ID3D11DeviceContext + img *image.RGBA + out [2]*image.RGBA + outIdx int + width int + height int +} + +func newDXGICapturer() (*dxgiCapturer, error) { + device, deviceCtx, err := d3d11.NewD3D11Device() + if err != nil { + return nil, fmt.Errorf("create D3D11 device: %w", err) + } + + dup, err := outputduplication.NewIDXGIOutputDuplication(device, deviceCtx, 0) + if err != nil { + device.Release() + deviceCtx.Release() + return nil, fmt.Errorf("create output duplication: %w", err) + } + + w, h := screenSize() + if w == 0 || h == 0 { + dup.Release() + device.Release() + deviceCtx.Release() + return nil, fmt.Errorf("screen dimensions are zero") + } + + rect := image.Rect(0, 0, w, h) + c := &dxgiCapturer{ + dup: dup, + device: device, + ctx: deviceCtx, + img: image.NewRGBA(rect), + out: [2]*image.RGBA{image.NewRGBA(rect), image.NewRGBA(rect)}, + width: w, + height: h, + } + + // Grab the initial frame with a longer timeout to ensure we have + // a valid image before returning. + _ = dup.GetImage(c.img, 2000) + + return c, nil +} + +func (c *dxgiCapturer) capture() (*image.RGBA, error) { + err := c.dup.GetImage(c.img, 100) + if err != nil && !errors.Is(err, outputduplication.ErrNoImageYet) { + return nil, err + } + + // Copy into the next output buffer. The DesktopCapturer hands out the + // returned pointer to VNC sessions that read pixels concurrently, so we + // alternate between two pre-allocated buffers instead of allocating per frame. + out := c.out[c.outIdx] + c.outIdx ^= 1 + copy(out.Pix, c.img.Pix) + return out, nil +} + +func (c *dxgiCapturer) close() { + if c.dup != nil { + c.dup.Release() + c.dup = nil + } + if c.ctx != nil { + c.ctx.Release() + c.ctx = nil + } + if c.device != nil { + c.device.Release() + c.device = nil + } +} diff --git a/client/vnc/server/capture_fb_freebsd.go b/client/vnc/server/capture_fb_freebsd.go new file mode 100644 index 00000000000..5e557855683 --- /dev/null +++ b/client/vnc/server/capture_fb_freebsd.go @@ -0,0 +1,148 @@ +//go:build freebsd + +package server + +import ( + "fmt" + "image" + "sync" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +// FreeBSD vt(4) framebuffer ioctl numbers from sys/fbio.h. +// +// #define FBIOGTYPE _IOR('F', 0, struct fbtype) +// +// _IOR(g, n, t) on FreeBSD: dir=2 (read) <<30 | (sizeof(t) & 0x1fff)<<16 +// | (g<<8) | n. sizeof(struct fbtype)=24 → 0x40184600. +const fbioGType = 0x40184600 + +func defaultFBPath() string { return "/dev/ttyv0" } + +// fbType mirrors FreeBSD's struct fbtype. +type fbType struct { + FbType int32 + FbHeight int32 + FbWidth int32 + FbDepth int32 + FbCMSize int32 + FbSize int32 +} + +// FBCapturer reads pixels from FreeBSD's vt(4) framebuffer device. The +// vt(4) console exposes the active framebuffer via ttyv0 with FBIOGTYPE +// for geometry and mmap for backing memory. Pixel layout is assumed to +// be 32bpp BGRA (the common case for KMS-backed vt); fbtype doesn't +// expose channel offsets, so we don't try to handle exotic layouts here. +type FBCapturer struct { + mu sync.Mutex + path string + fd int + mmap []byte + w, h int + bpp int + stride int + closeOnce sync.Once +} + +// NewFBCapturer opens the given vt(4) device and queries its geometry. +func NewFBCapturer(path string) (*FBCapturer, error) { + if path == "" { + path = defaultFBPath() + } + fd, err := unix.Open(path, unix.O_RDWR, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + var fbt fbType + if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGType, uintptr(unsafe.Pointer(&fbt))); e != 0 { + unix.Close(fd) + return nil, fmt.Errorf("FBIOGTYPE: %v", e) + } + if fbt.FbDepth != 16 && fbt.FbDepth != 24 && fbt.FbDepth != 32 { + unix.Close(fd) + return nil, fmt.Errorf("unsupported framebuffer depth: %d", fbt.FbDepth) + } + if fbt.FbWidth <= 0 || fbt.FbHeight <= 0 || fbt.FbSize <= 0 { + unix.Close(fd) + return nil, fmt.Errorf("invalid framebuffer geometry: %dx%d size=%d", fbt.FbWidth, fbt.FbHeight, fbt.FbSize) + } + + mm, err := unix.Mmap(fd, 0, int(fbt.FbSize), unix.PROT_READ, unix.MAP_SHARED) + if err != nil { + unix.Close(fd) + return nil, fmt.Errorf("mmap %s: %w (vt may not support mmap on this driver, e.g. virtio_gpu)", path, err) + } + + bpp := int(fbt.FbDepth) + stride := int(fbt.FbWidth) * (bpp / 8) + c := &FBCapturer{ + path: path, + fd: fd, // valid fd >= 0; we use -1 as the closed sentinel + mmap: mm, + w: int(fbt.FbWidth), + h: int(fbt.FbHeight), + bpp: bpp, + stride: stride, + } + log.Infof("framebuffer capturer ready: %s %dx%d bpp=%d (freebsd vt)", path, c.w, c.h, c.bpp) + return c, nil +} + +// Width returns the framebuffer width. +func (c *FBCapturer) Width() int { return c.w } + +// Height returns the framebuffer height. +func (c *FBCapturer) Height() int { return c.h } + +// Capture allocates a fresh image and fills it with the current +// framebuffer contents. +func (c *FBCapturer) Capture() (*image.RGBA, error) { + img := image.NewRGBA(image.Rect(0, 0, c.w, c.h)) + if err := c.CaptureInto(img); err != nil { + return nil, err + } + return img, nil +} + +// CaptureInto reads the framebuffer directly into dst.Pix. Assumes BGRA +// for 32bpp; the FreeBSD fbtype struct doesn't expose channel offsets. +func (c *FBCapturer) CaptureInto(dst *image.RGBA) error { + c.mu.Lock() + defer c.mu.Unlock() + if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h { + return fmt.Errorf("dst size mismatch: dst=%dx%d fb=%dx%d", + dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h) + } + switch c.bpp { + case 32: + // vt(4) on KMS framebuffers is BGRA: byte 0=B, 1=G, 2=R. + swizzleBGRAtoRGBA(dst.Pix, c.mmap[:c.h*c.stride]) + case 24: + swizzleFB24(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h) + case 16: + swizzleFB16RGB565(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h) + } + return nil +} + +// Close releases the framebuffer mmap and file descriptor. Serialized with +// CaptureInto via c.mu so an in-flight capture can't read freed memory. +func (c *FBCapturer) Close() { + c.closeOnce.Do(func() { + c.mu.Lock() + defer c.mu.Unlock() + if c.mmap != nil { + _ = unix.Munmap(c.mmap) + c.mmap = nil + } + if c.fd >= 0 { + _ = unix.Close(c.fd) + c.fd = -1 + } + }) +} diff --git a/client/vnc/server/capture_fb_linux.go b/client/vnc/server/capture_fb_linux.go new file mode 100644 index 00000000000..9e33a22a245 --- /dev/null +++ b/client/vnc/server/capture_fb_linux.go @@ -0,0 +1,230 @@ +//go:build linux && !android + +package server + +import ( + "encoding/binary" + "fmt" + "image" + "sync" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +// Linux framebuffer ioctls (linux/fb.h). +const ( + fbioGetVScreenInfo = 0x4600 + fbioGetFScreenInfo = 0x4602 +) + +func defaultFBPath() string { return "/dev/fb0" } + +// fbVarScreenInfo mirrors the kernel's fb_var_screeninfo. Only the +// fields we use are mapped; the rest are absorbed into _padN. +type fbVarScreenInfo struct { + Xres, Yres uint32 + XresVirtual, YresVirtual uint32 + XOffset, YOffset uint32 + BitsPerPixel uint32 + Grayscale uint32 + RedOffset, RedLen, RedMSBR uint32 + GreenOffset, GreenLen, GreenMSBR uint32 + BlueOffset, BlueLen, BlueMSBR uint32 + TranspOffset, TranspLen, TranspM uint32 + NonStd uint32 + Activate uint32 + Height, Width uint32 + AccelFlags uint32 + PixClock uint32 + LeftMargin, RightMargin uint32 + UpperMargin, LowerMargin uint32 + HsyncLen, VsyncLen uint32 + Sync uint32 + Vmode uint32 + Rotate uint32 + Colorspace uint32 + _pad [4]uint32 +} + +// fbFixScreenInfo mirrors fb_fix_screeninfo. We only need LineLength. +type fbFixScreenInfo struct { + IDStr [16]byte + SmemStart uint64 + SmemLen uint32 + Type uint32 + TypeAux uint32 + Visual uint32 + XPanStep uint16 + YPanStep uint16 + YWrapStep uint16 + _pad0 uint16 + LineLength uint32 + MmioStart uint64 + MmioLen uint32 + Accel uint32 + Capabilities uint16 + _reserved [2]uint16 +} + +// FBCapturer reads pixels straight from the Linux framebuffer device. +// Used as a fallback when X11 isn't available, e.g. on a headless box at +// the kernel console or the display manager's pre-login screen on machines +// without an Xorg server. The framebuffer must be mmap()-able under our +// process privileges (typically the netbird service runs as root). +type FBCapturer struct { + mu sync.Mutex + path string + fd int + mmap []byte + w, h int + bpp int + stride int + rOff uint32 + gOff uint32 + bOff uint32 + rLen uint32 + gLen uint32 + bLen uint32 + closeOnce sync.Once +} + +// NewFBCapturer opens the given framebuffer device (/dev/fbN) and +// queries its current geometry + pixel format. +func NewFBCapturer(path string) (*FBCapturer, error) { + if path == "" { + path = "/dev/fb0" + } + fd, err := unix.Open(path, unix.O_RDONLY, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + var vinfo fbVarScreenInfo + if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGetVScreenInfo, uintptr(unsafe.Pointer(&vinfo))); e != 0 { + unix.Close(fd) + return nil, fmt.Errorf("FBIOGET_VSCREENINFO: %v", e) + } + var finfo fbFixScreenInfo + if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGetFScreenInfo, uintptr(unsafe.Pointer(&finfo))); e != 0 { + unix.Close(fd) + return nil, fmt.Errorf("FBIOGET_FSCREENINFO: %v", e) + } + + bpp := int(vinfo.BitsPerPixel) + if bpp != 16 && bpp != 24 && bpp != 32 { + unix.Close(fd) + return nil, fmt.Errorf("unsupported framebuffer bpp: %d", bpp) + } + + size := int(finfo.LineLength) * int(vinfo.Yres) + if size <= 0 { + unix.Close(fd) + return nil, fmt.Errorf("invalid framebuffer dimensions: stride=%d h=%d", finfo.LineLength, vinfo.Yres) + } + + mm, err := unix.Mmap(fd, 0, size, unix.PROT_READ, unix.MAP_SHARED) + if err != nil { + unix.Close(fd) + return nil, fmt.Errorf("mmap %s: %w", path, err) + } + + c := &FBCapturer{ + path: path, + fd: fd, + mmap: mm, + w: int(vinfo.Xres), + h: int(vinfo.Yres), + bpp: bpp, + stride: int(finfo.LineLength), + rOff: vinfo.RedOffset, + gOff: vinfo.GreenOffset, + bOff: vinfo.BlueOffset, + rLen: vinfo.RedLen, + gLen: vinfo.GreenLen, + bLen: vinfo.BlueLen, + } + log.Infof("framebuffer capturer ready: %s %dx%d bpp=%d r=%d/%d g=%d/%d b=%d/%d", + path, c.w, c.h, c.bpp, c.rOff, c.rLen, c.gOff, c.gLen, c.bOff, c.bLen) + return c, nil +} + +// Width returns the framebuffer width in pixels. +func (c *FBCapturer) Width() int { return c.w } + +// Height returns the framebuffer height in pixels. +func (c *FBCapturer) Height() int { return c.h } + +// Capture allocates a fresh image and fills it with the current +// framebuffer contents. +func (c *FBCapturer) Capture() (*image.RGBA, error) { + img := image.NewRGBA(image.Rect(0, 0, c.w, c.h)) + if err := c.CaptureInto(img); err != nil { + return nil, err + } + return img, nil +} + +// CaptureInto reads the framebuffer directly into dst.Pix. +func (c *FBCapturer) CaptureInto(dst *image.RGBA) error { + c.mu.Lock() + defer c.mu.Unlock() + + if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h { + return fmt.Errorf("dst size mismatch: dst=%dx%d fb=%dx%d", + dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h) + } + + switch c.bpp { + case 32: + swizzleFB32(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h, channelShifts{R: c.rOff, G: c.gOff, B: c.bOff}) + case 24: + swizzleFB24(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h) + case 16: + swizzleFB16RGB565(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h) + } + return nil +} + +// Close releases the framebuffer mmap and file descriptor. Serialized with +// CaptureInto via c.mu so an in-flight capture can't read freed memory. +func (c *FBCapturer) Close() { + c.closeOnce.Do(func() { + c.mu.Lock() + defer c.mu.Unlock() + if c.mmap != nil { + _ = unix.Munmap(c.mmap) + c.mmap = nil + } + if c.fd >= 0 { + _ = unix.Close(c.fd) + c.fd = -1 + } + }) +} + +// channelShifts groups the bit offsets for the R/G/B channels in a packed +// uint32 framebuffer pixel. Bundling avoids drowning per-row callers in a +// 9-parameter signature. +type channelShifts struct { + R, G, B uint32 +} + +// swizzleFB32 handles 32-bit framebuffers with arbitrary R/G/B channel +// offsets. Pulls one pixel per uint32, then masks each channel into the +// destination RGBA byte order. +func swizzleFB32(dst []byte, dstStride int, src []byte, srcStride, w, h int, shifts channelShifts) { + for y := 0; y < h; y++ { + srcRow := src[y*srcStride : y*srcStride+w*4] + dstRow := dst[y*dstStride:] + for x := 0; x < w; x++ { + pix := binary.LittleEndian.Uint32(srcRow[x*4 : x*4+4]) + dstRow[x*4+0] = byte(pix >> shifts.R) + dstRow[x*4+1] = byte(pix >> shifts.G) + dstRow[x*4+2] = byte(pix >> shifts.B) + dstRow[x*4+3] = 0xff + } + } +} + diff --git a/client/vnc/server/capture_fb_unix.go b/client/vnc/server/capture_fb_unix.go new file mode 100644 index 00000000000..da57dcc2c05 --- /dev/null +++ b/client/vnc/server/capture_fb_unix.go @@ -0,0 +1,150 @@ +//go:build (linux && !android) || freebsd + +package server + +import ( + "image" + "sync" +) + +// FBPoller wraps FBCapturer with the same lifecycle (ClientConnect / +// ClientDisconnect, lazy init) as X11Poller, so it slots into the same +// session plumbing without code changes upstream. The concrete +// FBCapturer is platform-specific (capture_fb_linux.go / _freebsd.go); +// this file owns the cross-platform glue. +type FBPoller struct { + mu sync.Mutex + path string + capturer *FBCapturer + w, h int + clients int32 +} + +// NewFBPoller returns a poller that opens path on first use. Empty path +// defaults to /dev/fb0 on Linux and /dev/ttyv0 on FreeBSD. +func NewFBPoller(path string) *FBPoller { + if path == "" { + path = defaultFBPath() + } + return &FBPoller{path: path} +} + +// ClientConnect eagerly initialises the capturer on first connect. +func (p *FBPoller) ClientConnect() { + p.mu.Lock() + defer p.mu.Unlock() + p.clients++ + if p.clients == 1 { + _ = p.ensureCapturerLocked() + } +} + +// ClientDisconnect closes the capturer when the last client leaves. +func (p *FBPoller) ClientDisconnect() { + p.mu.Lock() + defer p.mu.Unlock() + p.clients-- + if p.clients <= 0 && p.capturer != nil { + p.capturer.Close() + p.capturer = nil + } +} + +// Width returns the framebuffer width, doing lazy init if needed. +func (p *FBPoller) Width() int { + p.mu.Lock() + defer p.mu.Unlock() + _ = p.ensureCapturerLocked() + return p.w +} + +// Height returns the framebuffer height, doing lazy init if needed. +func (p *FBPoller) Height() int { + p.mu.Lock() + defer p.mu.Unlock() + _ = p.ensureCapturerLocked() + return p.h +} + +// Capture takes a fresh frame. +func (p *FBPoller) Capture() (*image.RGBA, error) { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ensureCapturerLocked(); err != nil { + return nil, err + } + return p.capturer.Capture() +} + +// CaptureInto fills dst directly. +func (p *FBPoller) CaptureInto(dst *image.RGBA) error { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ensureCapturerLocked(); err != nil { + return err + } + return p.capturer.CaptureInto(dst) +} + +// Close releases all framebuffer resources. +func (p *FBPoller) Close() { + p.mu.Lock() + defer p.mu.Unlock() + if p.capturer != nil { + p.capturer.Close() + p.capturer = nil + } +} + +func (p *FBPoller) ensureCapturerLocked() error { + if p.capturer != nil { + return nil + } + c, err := NewFBCapturer(p.path) + if err != nil { + return err + } + p.capturer = c + p.w, p.h = c.Width(), c.Height() + return nil +} + + +var _ ScreenCapturer = (*FBPoller)(nil) +var _ captureIntoer = (*FBPoller)(nil) + +// swizzleFB24 handles 24-bit packed framebuffers (B,G,R triplets). +// Shared between Linux and FreeBSD framebuffer paths. +func swizzleFB24(dst []byte, dstStride int, src []byte, srcStride, w, h int) { + for y := 0; y < h; y++ { + srcRow := src[y*srcStride : y*srcStride+w*3] + dstRow := dst[y*dstStride:] + for x := 0; x < w; x++ { + b := srcRow[x*3+0] + g := srcRow[x*3+1] + r := srcRow[x*3+2] + dstRow[x*4+0] = r + dstRow[x*4+1] = g + dstRow[x*4+2] = b + dstRow[x*4+3] = 0xff + } + } +} + +// swizzleFB16RGB565 handles 16bpp RGB 565 framebuffers. +func swizzleFB16RGB565(dst []byte, dstStride int, src []byte, srcStride, w, h int) { + for y := 0; y < h; y++ { + srcRow := src[y*srcStride : y*srcStride+w*2] + dstRow := dst[y*dstStride:] + for x := 0; x < w; x++ { + pix := uint16(srcRow[x*2]) | uint16(srcRow[x*2+1])<<8 + r := byte((pix >> 11) & 0x1f) + g := byte((pix >> 5) & 0x3f) + b := byte(pix & 0x1f) + dstRow[x*4+0] = (r << 3) | (r >> 2) + dstRow[x*4+1] = (g << 2) | (g >> 4) + dstRow[x*4+2] = (b << 3) | (b >> 2) + dstRow[x*4+3] = 0xff + } + } +} diff --git a/client/vnc/server/capture_windows.go b/client/vnc/server/capture_windows.go new file mode 100644 index 00000000000..28d0f2911e8 --- /dev/null +++ b/client/vnc/server/capture_windows.go @@ -0,0 +1,544 @@ +//go:build windows + +package server + +import ( + "fmt" + "image" + "runtime" + "sync" + "sync/atomic" + "time" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +var ( + gdi32 = windows.NewLazySystemDLL("gdi32.dll") + user32 = windows.NewLazySystemDLL("user32.dll") + + procGetDC = user32.NewProc("GetDC") + procReleaseDC = user32.NewProc("ReleaseDC") + procCreateCompatDC = gdi32.NewProc("CreateCompatibleDC") + procCreateDIBSection = gdi32.NewProc("CreateDIBSection") + procSelectObject = gdi32.NewProc("SelectObject") + procDeleteObject = gdi32.NewProc("DeleteObject") + procDeleteDC = gdi32.NewProc("DeleteDC") + procBitBlt = gdi32.NewProc("BitBlt") + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + + // Desktop switching for service/Session 0 capture. + procOpenInputDesktop = user32.NewProc("OpenInputDesktop") + procSetThreadDesktop = user32.NewProc("SetThreadDesktop") + procCloseDesktop = user32.NewProc("CloseDesktop") + procOpenWindowStation = user32.NewProc("OpenWindowStationW") + procSetProcessWindowStation = user32.NewProc("SetProcessWindowStation") + procCloseWindowStation = user32.NewProc("CloseWindowStation") + procGetUserObjectInformationW = user32.NewProc("GetUserObjectInformationW") +) + +const uoiName = 2 + +const ( + smCxScreen = 0 + smCyScreen = 1 + srccopy = 0x00CC0020 + captureBlt = 0x40000000 + dibRgbColors = 0 +) + +type bitmapInfoHeader struct { + Size uint32 + Width int32 + Height int32 + Planes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter int32 + YPelsPerMeter int32 + ClrUsed uint32 + ClrImportant uint32 +} + +type bitmapInfo struct { + Header bitmapInfoHeader +} + +// setupInteractiveWindowStation associates the current process with WinSta0, +// the interactive window station. This is required for a SYSTEM service in +// Session 0 to call OpenInputDesktop for screen capture and input injection. +func setupInteractiveWindowStation() error { + name, err := windows.UTF16PtrFromString("WinSta0") + if err != nil { + return fmt.Errorf("UTF16 WinSta0: %w", err) + } + hWinSta, _, err := procOpenWindowStation.Call( + uintptr(unsafe.Pointer(name)), + 0, + uintptr(windows.MAXIMUM_ALLOWED), + ) + if hWinSta == 0 { + return fmt.Errorf("OpenWindowStation(WinSta0): %w", err) + } + r, _, err := procSetProcessWindowStation.Call(hWinSta) + if r == 0 { + _, _, _ = procCloseWindowStation.Call(hWinSta) + return fmt.Errorf("SetProcessWindowStation: %w", err) + } + log.Info("process window station set to WinSta0 (interactive)") + return nil +} + +func screenSize() (int, int) { + w, _, _ := procGetSystemMetrics.Call(uintptr(smCxScreen)) + h, _, _ := procGetSystemMetrics.Call(uintptr(smCyScreen)) + return int(w), int(h) +} + +func getDesktopName(hDesk uintptr) string { + var buf [256]uint16 + var needed uint32 + _, _, _ = procGetUserObjectInformationW.Call(hDesk, uoiName, + uintptr(unsafe.Pointer(&buf[0])), 512, + uintptr(unsafe.Pointer(&needed))) + return windows.UTF16ToString(buf[:]) +} + +// switchToInputDesktop opens the desktop currently receiving user input +// and sets it as the calling OS thread's desktop. Must be called from a +// goroutine locked to its OS thread via runtime.LockOSThread(). +func switchToInputDesktop() (bool, string) { + hDesk, _, _ := procOpenInputDesktop.Call(0, 0, uintptr(windows.MAXIMUM_ALLOWED)) + if hDesk == 0 { + return false, "" + } + name := getDesktopName(hDesk) + ret, _, _ := procSetThreadDesktop.Call(hDesk) + _, _, _ = procCloseDesktop.Call(hDesk) + return ret != 0, name +} + +// gdiCapturer captures the desktop screen using GDI BitBlt. +// GDI objects (DC, DIBSection) are allocated once and reused across frames. +type gdiCapturer struct { + mu sync.Mutex + width int + height int + + // Pre-allocated GDI resources, reused across captures. + memDC uintptr + bmp uintptr + bits uintptr +} + +func newGDICapturer() (*gdiCapturer, error) { + w, h := screenSize() + if w == 0 || h == 0 { + return nil, fmt.Errorf("screen dimensions are zero") + } + c := &gdiCapturer{width: w, height: h} + if err := c.allocGDI(); err != nil { + return nil, err + } + return c, nil +} + +// allocGDI pre-allocates the compatible DC and DIB section for reuse. +func (c *gdiCapturer) allocGDI() error { + screenDC, _, _ := procGetDC.Call(0) + if screenDC == 0 { + return fmt.Errorf("GetDC returned 0") + } + defer func() { _, _, _ = procReleaseDC.Call(0, screenDC) }() + + memDC, _, _ := procCreateCompatDC.Call(screenDC) + if memDC == 0 { + return fmt.Errorf("CreateCompatibleDC returned 0") + } + + bi := bitmapInfo{ + Header: bitmapInfoHeader{ + Size: uint32(unsafe.Sizeof(bitmapInfoHeader{})), + Width: int32(c.width), + Height: -int32(c.height), // negative = top-down DIB + Planes: 1, + BitCount: 32, + }, + } + + var bits uintptr + bmp, _, _ := procCreateDIBSection.Call( + screenDC, + uintptr(unsafe.Pointer(&bi)), + dibRgbColors, + uintptr(unsafe.Pointer(&bits)), + 0, 0, + ) + if bmp == 0 || bits == 0 { + _, _, _ = procDeleteDC.Call(memDC) + return fmt.Errorf("CreateDIBSection returned 0") + } + + _, _, _ = procSelectObject.Call(memDC, bmp) + + c.memDC = memDC + c.bmp = bmp + c.bits = bits + return nil +} + +func (c *gdiCapturer) close() { c.freeGDI() } + +// freeGDI releases pre-allocated GDI resources. +func (c *gdiCapturer) freeGDI() { + if c.bmp != 0 { + _, _, _ = procDeleteObject.Call(c.bmp) + c.bmp = 0 + } + if c.memDC != 0 { + _, _, _ = procDeleteDC.Call(c.memDC) + c.memDC = 0 + } + c.bits = 0 +} + +func (c *gdiCapturer) capture() (*image.RGBA, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.memDC == 0 { + return nil, fmt.Errorf("GDI resources not allocated") + } + + screenDC, _, _ := procGetDC.Call(0) + if screenDC == 0 { + return nil, fmt.Errorf("GetDC returned 0") + } + defer func() { _, _, _ = procReleaseDC.Call(0, screenDC) }() + + // SRCCOPY|CAPTUREBLT: CAPTUREBLT forces inclusion of layered/topmost + // windows in the capture and is required for GDI BitBlt to return live + // pixels when the session is rendered through RDP / DWM-composited + // surfaces. Without it BitBlt reads the backing-store DIB which is + // often empty (all-black) on RDP and headless sessions. + ret, _, _ := procBitBlt.Call(c.memDC, 0, 0, uintptr(c.width), uintptr(c.height), + screenDC, 0, 0, srccopy|captureBlt) + if ret == 0 { + return nil, fmt.Errorf("BitBlt returned 0") + } + + n := c.width * c.height * 4 + raw := unsafe.Slice((*byte)(unsafe.Pointer(c.bits)), n) + + // GDI gives BGRA, the RFB encoder expects RGBA (img.Pix layout). + // Swap R and B in bulk using uint32 operations (one load + mask + shift + // per pixel instead of three separate byte assignments). + img := image.NewRGBA(image.Rect(0, 0, c.width, c.height)) + swizzleBGRAtoRGBA(img.Pix, raw) + return img, nil +} + +// DesktopCapturer captures the interactive desktop, handling desktop transitions +// (login screen, UAC prompts). A dedicated OS-locked goroutine continuously +// captures frames on demand via a dedicated OS-locked goroutine (required +// because DXGI's D3D11 device context is not thread-safe). Sessions drive +// timing by calling Capture(); a short staleness cache coalesces concurrent +// requests. Capture pauses automatically when no clients are connected. +type DesktopCapturer struct { + mu sync.Mutex + w, h int + + // lastFrame/lastAt implement a small staleness cache so multiple + // near-simultaneous Capture calls share one DXGI round-trip. + lastFrame *image.RGBA + lastAt time.Time + + // clients tracks the number of active VNC sessions. When zero, the + // worker goroutine releases the underlying capturer. + clients atomic.Int32 + + // reqCh carries capture requests from sessions to the OS-locked worker. + reqCh chan captureReq + // wake is signaled when a client connects and the worker should resume. + wake chan struct{} + // done is closed when Close is called, terminating the worker. + done chan struct{} +} + +// captureReq is a single capture request awaiting a reply. Reply channel is +// buffered to size 1 so the worker never blocks on a sender that's gone. +type captureReq struct { + reply chan captureReply +} + +type captureReply struct { + img *image.RGBA + err error +} + +// NewDesktopCapturer creates an on-demand capturer for the active desktop. +func NewDesktopCapturer() *DesktopCapturer { + c := &DesktopCapturer{ + wake: make(chan struct{}, 1), + done: make(chan struct{}), + reqCh: make(chan captureReq), + } + go c.worker() + return c +} + +// ClientConnect increments the active client count, resuming capture if needed. +func (c *DesktopCapturer) ClientConnect() { + c.clients.Add(1) + select { + case c.wake <- struct{}{}: + default: + } +} + +// ClientDisconnect decrements the active client count. +func (c *DesktopCapturer) ClientDisconnect() { + c.clients.Add(-1) +} + +// Close stops the capture loop and releases resources. +func (c *DesktopCapturer) Close() { + select { + case <-c.done: + default: + close(c.done) + } +} + +// Width returns the current screen width, triggering a capture if the +// worker hasn't initialised yet. validateCapturer depends on Width/Height +// becoming non-zero promptly after ClientConnect so it doesn't reject +// brand-new sessions. +func (c *DesktopCapturer) Width() int { + c.mu.Lock() + w := c.w + c.mu.Unlock() + if w == 0 { + _, _ = c.Capture() + c.mu.Lock() + w = c.w + c.mu.Unlock() + } + return w +} + +// Height returns the current screen height, triggering a capture if the +// worker hasn't initialised yet (see Width). +func (c *DesktopCapturer) Height() int { + c.mu.Lock() + h := c.h + c.mu.Unlock() + if h == 0 { + _, _ = c.Capture() + c.mu.Lock() + h = c.h + c.mu.Unlock() + } + return h +} + +// Capture returns a freshly captured frame, serving from a short staleness +// cache when multiple sessions ask within freshWindow of each other. All +// real DXGI/GDI work happens on the OS-locked worker goroutine. +func (c *DesktopCapturer) Capture() (*image.RGBA, error) { + c.mu.Lock() + if c.lastFrame != nil && time.Since(c.lastAt) < freshWindow { + img := c.lastFrame + c.mu.Unlock() + return img, nil + } + c.mu.Unlock() + + reply := make(chan captureReply, 1) + select { + case c.reqCh <- captureReq{reply: reply}: + case <-c.done: + return nil, fmt.Errorf("capturer closed") + } + select { + case r := <-reply: + if r.err != nil { + return nil, r.err + } + c.mu.Lock() + c.lastFrame = r.img + c.lastAt = time.Now() + c.mu.Unlock() + return r.img, nil + case <-c.done: + return nil, fmt.Errorf("capturer closed") + } +} + +// waitForClient blocks until a client connects or the capturer is closed. +func (c *DesktopCapturer) waitForClient() bool { + if c.clients.Load() > 0 { + return true + } + select { + case <-c.wake: + return true + case <-c.done: + return false + } +} + +// worker owns DXGI/GDI state on its OS-locked thread and services capture +// requests from sessions. No background ticker: a capture happens only when +// a session asks for one (throttled by Capture()'s staleness cache). +func (c *DesktopCapturer) worker() { + runtime.LockOSThread() + + // When running as a Windows service (Session 0), we need to attach to the + // interactive window station before OpenInputDesktop will succeed. + if err := setupInteractiveWindowStation(); err != nil { + log.Warnf("attach to interactive window station: %v", err) + } + + w := &captureWorker{c: c} + defer w.closeCapturer() + + for { + if !c.waitForClient() { + return + } + // Drop the capturer when all clients have disconnected so we don't + // hold the DXGI duplication or GDI DC on an idle peer. + if c.clients.Load() <= 0 { + w.closeCapturer() + continue + } + if !w.handleNextRequest() { + return + } + } +} + +// frameCapturer is the per-backend interface used by the worker. DXGI and +// GDI implementations both satisfy it. +type frameCapturer interface { + capture() (*image.RGBA, error) + close() +} + +// captureWorker owns the worker goroutine's mutable state. Extracted into a +// struct so the request/desktop/init logic can live on small methods and the +// outer worker() stays a thin loop. +type captureWorker struct { + c *DesktopCapturer + cap frameCapturer + desktopFails int + lastDesktop string + nextInitRetry time.Time +} + +// handleNextRequest waits for either shutdown or a capture request and runs +// the request through prepCapturer/capture. Returns false when the worker +// should exit. +func (w *captureWorker) handleNextRequest() bool { + select { + case <-w.c.done: + return false + case req := <-w.c.reqCh: + w.serveRequest(req) + return true + } +} + +func (w *captureWorker) serveRequest(req captureReq) { + fc, err := w.prepCapturer() + if err != nil { + req.reply <- captureReply{err: err} + return + } + img, err := fc.capture() + if err != nil { + log.Debugf("capture: %v", err) + w.closeCapturer() + w.nextInitRetry = time.Now().Add(100 * time.Millisecond) + req.reply <- captureReply{err: err} + return + } + req.reply <- captureReply{img: img} +} + +// prepCapturer switches to the input desktop, handles desktop-change +// teardown, and creates the underlying capturer on demand. Backoff state is +// tracked across calls via w.nextInitRetry. +func (w *captureWorker) prepCapturer() (frameCapturer, error) { + if err := w.refreshDesktop(); err != nil { + return nil, err + } + if w.cap != nil { + return w.cap, nil + } + if time.Now().Before(w.nextInitRetry) { + return nil, fmt.Errorf("capturer init backing off") + } + fc, err := w.createCapturer() + if err != nil { + w.nextInitRetry = time.Now().Add(500 * time.Millisecond) + return nil, err + } + w.cap = fc + sw, sh := screenSize() + w.c.mu.Lock() + w.c.w, w.c.h = sw, sh + w.c.mu.Unlock() + log.Infof("screen capturer ready: %dx%d", sw, sh) + return w.cap, nil +} + +// refreshDesktop tracks the active input desktop. When it changes (lock +// screen, fast-user-switch) the existing capturer is dropped so the next +// call rebuilds one against the new desktop. +func (w *captureWorker) refreshDesktop() error { + ok, desk := switchToInputDesktop() + if !ok { + w.desktopFails++ + if w.desktopFails == 1 || w.desktopFails%100 == 0 { + log.Warnf("switchToInputDesktop failed (count=%d), no interactive desktop session?", w.desktopFails) + } + return fmt.Errorf("no interactive desktop") + } + if w.desktopFails > 0 { + log.Infof("switchToInputDesktop recovered after %d failures, desktop=%q", w.desktopFails, desk) + w.desktopFails = 0 + } + if desk != w.lastDesktop { + log.Infof("desktop changed: %q -> %q", w.lastDesktop, desk) + w.lastDesktop = desk + w.closeCapturer() + } + return nil +} + +func (w *captureWorker) createCapturer() (frameCapturer, error) { + dc, err := newDXGICapturer() + if err == nil { + log.Info("using DXGI Desktop Duplication for capture") + return dc, nil + } + log.Debugf("DXGI unavailable (%v), falling back to GDI", err) + gc, err := newGDICapturer() + if err != nil { + return nil, err + } + log.Info("using GDI BitBlt for capture") + return gc, nil +} + +func (w *captureWorker) closeCapturer() { + if w.cap != nil { + w.cap.close() + w.cap = nil + } +} diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go new file mode 100644 index 00000000000..d108aada16a --- /dev/null +++ b/client/vnc/server/capture_x11.go @@ -0,0 +1,479 @@ +//go:build (linux && !android) || freebsd + +package server + +import ( + "fmt" + "image" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/jezek/xgb" + "github.com/jezek/xgb/xproto" +) + +// X11Capturer captures the screen from an X11 display using the MIT-SHM extension. +type X11Capturer struct { + mu sync.Mutex + conn *xgb.Conn + screen *xproto.ScreenInfo + w, h int + shmID int + shmAddr []byte + shmSeg uint32 // shm.Seg + useSHM bool + // bufs double-buffers output images so the X11Poller's capture loop can + // overwrite one while the session is still encoding the other. Before + // this, a single reused buffer would race with the reader. Allocation + // happens on first use and on geometry change. + bufs [2]*image.RGBA + cur int +} + +// detectX11Display finds the active X11 display and sets DISPLAY/XAUTHORITY +// environment variables if needed. This is required when running as a system +// service where these vars aren't set. +func detectX11Display() { + if os.Getenv("DISPLAY") != "" { + return + } + + // Try /proc first (Linux), then ps fallback (FreeBSD and others). + if detectX11FromProc() { + return + } + if detectX11FromSockets() { + return + } +} + +// detectX11FromProc scans /proc/*/cmdline for Xorg (Linux). +func detectX11FromProc() bool { + entries, err := os.ReadDir("/proc") + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() { + continue + } + cmdline, err := os.ReadFile("/proc/" + e.Name() + "/cmdline") + if err != nil { + continue + } + if display, auth := parseXorgArgs(splitCmdline(cmdline)); display != "" { + setDisplayEnv(display, auth) + return true + } + } + return false +} + +// detectX11FromSockets checks /tmp/.X11-unix/ for X sockets and uses ps +// to find the auth file. Works on FreeBSD and other systems without /proc. +func detectX11FromSockets() bool { + entries, err := os.ReadDir("/tmp/.X11-unix") + if err != nil { + return false + } + + // Find the lowest display number. + for _, e := range entries { + name := e.Name() + if len(name) < 2 || name[0] != 'X' { + continue + } + display := ":" + name[1:] + os.Setenv("DISPLAY", display) + log.Infof("auto-detected DISPLAY=%s (from socket)", display) + + // Try to find -auth from ps output. + if auth := findXorgAuthFromPS(); auth != "" { + os.Setenv("XAUTHORITY", auth) + log.Infof("auto-detected XAUTHORITY=%s (from ps)", auth) + } + return true + } + return false +} + +// findXorgAuthFromPS runs ps to find Xorg and extract its -auth argument. +func findXorgAuthFromPS() string { + out, err := exec.Command("ps", "auxww").Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + if !strings.Contains(line, "Xorg") && !strings.Contains(line, "/X ") { + continue + } + fields := strings.Fields(line) + for i, f := range fields { + if f == "-auth" && i+1 < len(fields) { + return fields[i+1] + } + } + } + return "" +} + +func parseXorgArgs(args []string) (display, auth string) { + if len(args) == 0 { + return "", "" + } + base := args[0] + if !(base == "Xorg" || base == "X" || len(base) > 0 && base[len(base)-1] == 'X' || + strings.Contains(base, "/Xorg") || strings.Contains(base, "/X")) { + return "", "" + } + for i, arg := range args[1:] { + if len(arg) > 0 && arg[0] == ':' { + display = arg + } + if arg == "-auth" && i+2 < len(args) { + auth = args[i+2] + } + } + return display, auth +} + +func setDisplayEnv(display, auth string) { + os.Setenv("DISPLAY", display) + log.Infof("auto-detected DISPLAY=%s", display) + if auth != "" { + os.Setenv("XAUTHORITY", auth) + log.Infof("auto-detected XAUTHORITY=%s", auth) + } +} + +func splitCmdline(data []byte) []string { + var args []string + for _, b := range splitNull(data) { + if len(b) > 0 { + args = append(args, string(b)) + } + } + return args +} + +func splitNull(data []byte) [][]byte { + var parts [][]byte + start := 0 + for i, b := range data { + if b == 0 { + parts = append(parts, data[start:i]) + start = i + 1 + } + } + if start < len(data) { + parts = append(parts, data[start:]) + } + return parts +} + +// NewX11Capturer connects to the X11 display and sets up shared memory capture. +func NewX11Capturer(display string) (*X11Capturer, error) { + if display == "" { + detectX11Display() + display = os.Getenv("DISPLAY") + } + if display == "" { + return nil, fmt.Errorf("DISPLAY not set and no Xorg process found") + } + + conn, err := xgb.NewConnDisplay(display) + if err != nil { + return nil, fmt.Errorf("connect to X11 display %s: %w", display, err) + } + + setup := xproto.Setup(conn) + if len(setup.Roots) == 0 { + conn.Close() + return nil, fmt.Errorf("no X11 screens") + } + screen := setup.Roots[0] + + c := &X11Capturer{ + conn: conn, + screen: &screen, + w: int(screen.WidthInPixels), + h: int(screen.HeightInPixels), + } + + if err := c.initSHM(); err != nil { + log.Debugf("X11 SHM not available, using slow GetImage: %v", err) + } + + log.Infof("X11 capturer ready: %dx%d (display=%s, shm=%v)", c.w, c.h, display, c.useSHM) + return c, nil +} + +// initSHM is implemented in capture_x11_shm_linux.go (requires SysV SHM). +// On platforms without SysV SHM (FreeBSD), a stub returns an error and +// the capturer falls back to GetImage. + +// Width returns the screen width. +func (c *X11Capturer) Width() int { return c.w } + +// Height returns the screen height. +func (c *X11Capturer) Height() int { return c.h } + +// Capture returns the current screen as an RGBA image. +func (c *X11Capturer) Capture() (*image.RGBA, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.useSHM { + return c.captureSHM() + } + return c.captureGetImage() +} + +// CaptureInto fills the caller's destination buffer in one pass. The +// source path (SHM or fallback GetImage) writes directly into dst.Pix +// instead of going through the X11Capturer's internal double-buffer, +// saving one full-frame memcpy per capture. +func (c *X11Capturer) CaptureInto(dst *image.RGBA) error { + c.mu.Lock() + defer c.mu.Unlock() + if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h { + return fmt.Errorf("dst size mismatch: dst=%dx%d capturer=%dx%d", + dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h) + } + if c.useSHM { + return c.captureSHMInto(dst) + } + return c.captureGetImageInto(dst) +} + +func (c *X11Capturer) captureGetImageInto(dst *image.RGBA) error { + cookie := xproto.GetImage(c.conn, xproto.ImageFormatZPixmap, + xproto.Drawable(c.screen.Root), + 0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF) + reply, err := cookie.Reply() + if err != nil { + return fmt.Errorf("GetImage: %w", err) + } + n := c.w * c.h * 4 + if len(reply.Data) < n { + return fmt.Errorf("GetImage returned %d bytes, expected %d", len(reply.Data), n) + } + swizzleBGRAtoRGBA(dst.Pix, reply.Data) + return nil +} + +// captureSHM is implemented in capture_x11_shm_linux.go. + +func (c *X11Capturer) captureGetImage() (*image.RGBA, error) { + cookie := xproto.GetImage(c.conn, xproto.ImageFormatZPixmap, + xproto.Drawable(c.screen.Root), + 0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF) + + reply, err := cookie.Reply() + if err != nil { + return nil, fmt.Errorf("GetImage: %w", err) + } + + data := reply.Data + n := c.w * c.h * 4 + if len(data) < n { + return nil, fmt.Errorf("GetImage returned %d bytes, expected %d", len(data), n) + } + + img := c.nextBuffer() + swizzleBGRAtoRGBA(img.Pix, data) + return img, nil +} + +// nextBuffer returns the *image.RGBA the next capture should fill, advancing +// the double-buffer index. Reallocates on geometry change. +func (c *X11Capturer) nextBuffer() *image.RGBA { + c.cur ^= 1 + b := c.bufs[c.cur] + if b == nil || b.Rect.Dx() != c.w || b.Rect.Dy() != c.h { + b = image.NewRGBA(image.Rect(0, 0, c.w, c.h)) + c.bufs[c.cur] = b + } + return b +} + +// Close releases X11 resources. +func (c *X11Capturer) Close() { + c.closeSHM() + c.conn.Close() +} + +// closeSHM is implemented in capture_x11_shm_linux.go. + +// X11Poller wraps X11Capturer with a staleness-cached on-demand Capture: +// sessions drive captures themselves through the encoder goroutine, so we +// don't need a background ticker. The last result is cached for a short +// window so concurrent sessions coalesce into one capture. +// +// The capturer is allocated lazily on first use and released when all +// clients disconnect, so an idle peer holds no X connection or SHM segment. +type X11Poller struct { + mu sync.Mutex + + capturer *X11Capturer + w, h int + // closed at Close so callers can stop waiting on retry backoff. + done chan struct{} + + // lastFrame/lastAt implement a small cache: multiple near-simultaneous + // Capture calls (multi-client, or input-coalesced) return the same + // frame instead of hammering the X server. + lastFrame *image.RGBA + lastAt time.Time + + // initBackoffUntil throttles capturer re-init when the X server is + // unavailable or flapping. + initBackoffUntil time.Time + + clients atomic.Int32 + display string +} + +// initRetryBackoff gates capturer re-init attempts after a failure so we +// don't spin on X server errors. +const initRetryBackoff = 2 * time.Second + +// NewX11Poller creates a lazy on-demand capturer for the given X display. +func NewX11Poller(display string) *X11Poller { + return &X11Poller{ + display: display, + done: make(chan struct{}), + } +} + +// ClientConnect increments the active client count. The first client triggers +// eager capturer initialisation so that the first FBUpdateRequest doesn't +// pay the X11 connect + SHM attach latency. +func (p *X11Poller) ClientConnect() { + if p.clients.Add(1) == 1 { + p.mu.Lock() + _ = p.ensureCapturerLocked() + p.mu.Unlock() + } +} + +// ClientDisconnect decrements the active client count. On the last +// disconnect we close the underlying capturer so idle peers cost nothing. +func (p *X11Poller) ClientDisconnect() { + if p.clients.Add(-1) == 0 { + p.mu.Lock() + if p.capturer != nil { + p.capturer.Close() + p.capturer = nil + p.lastFrame = nil + } + p.mu.Unlock() + } +} + +// Close releases all resources. Subsequent Capture calls will fail. +func (p *X11Poller) Close() { + p.mu.Lock() + defer p.mu.Unlock() + select { + case <-p.done: + default: + close(p.done) + } + if p.capturer != nil { + p.capturer.Close() + p.capturer = nil + } +} + +// Width returns the screen width. Triggers lazy init if needed. +func (p *X11Poller) Width() int { + p.mu.Lock() + defer p.mu.Unlock() + _ = p.ensureCapturerLocked() + return p.w +} + +// Height returns the screen height. Triggers lazy init if needed. +func (p *X11Poller) Height() int { + p.mu.Lock() + defer p.mu.Unlock() + _ = p.ensureCapturerLocked() + return p.h +} + +// Capture returns a fresh frame, serving from the short-lived cache if a +// previous caller captured within freshWindow. +func (p *X11Poller) Capture() (*image.RGBA, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.lastFrame != nil && time.Since(p.lastAt) < freshWindow { + return p.lastFrame, nil + } + if err := p.ensureCapturerLocked(); err != nil { + return nil, err + } + img, err := p.capturer.Capture() + if err != nil { + // Drop the capturer so the next call re-inits; the X connection may + // have died (e.g. Xorg restart). + p.capturer.Close() + p.capturer = nil + p.initBackoffUntil = time.Now().Add(initRetryBackoff) + return nil, fmt.Errorf("x11 capture: %w", err) + } + p.lastFrame = img + p.lastAt = time.Now() + return img, nil +} + +// CaptureInto fills dst directly via the underlying capturer, bypassing +// the freshness cache. The session's prevFrame/curFrame swap means each +// session needs its own buffer anyway, so caching wouldn't help. +func (p *X11Poller) CaptureInto(dst *image.RGBA) error { + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.ensureCapturerLocked(); err != nil { + return err + } + if err := p.capturer.CaptureInto(dst); err != nil { + p.capturer.Close() + p.capturer = nil + p.initBackoffUntil = time.Now().Add(initRetryBackoff) + return fmt.Errorf("x11 capture: %w", err) + } + return nil +} + +// ensureCapturerLocked initialises the underlying X11Capturer if not +// already open. Caller must hold p.mu. +func (p *X11Poller) ensureCapturerLocked() error { + if p.capturer != nil { + return nil + } + select { + case <-p.done: + return fmt.Errorf("x11 capturer closed") + default: + } + if time.Now().Before(p.initBackoffUntil) { + return fmt.Errorf("x11 capturer unavailable (retry scheduled)") + } + c, err := NewX11Capturer(p.display) + if err != nil { + p.initBackoffUntil = time.Now().Add(initRetryBackoff) + log.Debugf("X11 capturer: %v", err) + return err + } + p.capturer = c + p.w, p.h = c.Width(), c.Height() + return nil +} diff --git a/client/vnc/server/capture_x11_shm_linux.go b/client/vnc/server/capture_x11_shm_linux.go new file mode 100644 index 00000000000..24edff4dff9 --- /dev/null +++ b/client/vnc/server/capture_x11_shm_linux.go @@ -0,0 +1,96 @@ +//go:build linux && !android + +package server + +import ( + "fmt" + "image" + + "github.com/jezek/xgb/shm" + "github.com/jezek/xgb/xproto" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +func (c *X11Capturer) initSHM() error { + if err := shm.Init(c.conn); err != nil { + return fmt.Errorf("init SHM extension: %w", err) + } + + size := c.w * c.h * 4 + id, err := unix.SysvShmGet(unix.IPC_PRIVATE, size, unix.IPC_CREAT|0600) + if err != nil { + return fmt.Errorf("shmget: %w", err) + } + + addr, err := unix.SysvShmAttach(id, 0, 0) + if err != nil { + if _, ctlErr := unix.SysvShmCtl(id, unix.IPC_RMID, nil); ctlErr != nil { + log.Debugf("shmctl IPC_RMID on attach failure: %v", ctlErr) + } + return fmt.Errorf("shmat: %w", err) + } + + if _, err := unix.SysvShmCtl(id, unix.IPC_RMID, nil); err != nil { + log.Debugf("shmctl IPC_RMID: %v", err) + } + + seg, err := shm.NewSegId(c.conn) + if err != nil { + if detachErr := unix.SysvShmDetach(addr); detachErr != nil { + log.Debugf("shmdt on new-seg failure: %v", detachErr) + } + return fmt.Errorf("new SHM seg: %w", err) + } + + if err := shm.AttachChecked(c.conn, seg, uint32(id), false).Check(); err != nil { + if detachErr := unix.SysvShmDetach(addr); detachErr != nil { + log.Debugf("shmdt on attach-checked failure: %v", detachErr) + } + return fmt.Errorf("SHM attach to X: %w", err) + } + + c.shmID = id + c.shmAddr = addr + c.shmSeg = uint32(seg) + c.useSHM = true + return nil +} + +func (c *X11Capturer) captureSHM() (*image.RGBA, error) { + if err := c.fillSHM(); err != nil { + return nil, err + } + img := c.nextBuffer() + swizzleBGRAtoRGBA(img.Pix, c.shmAddr[:c.w*c.h*4]) + return img, nil +} + +// captureSHMInto runs a single SHM GetImage and swizzles directly into the +// caller-provided destination, skipping the internal double-buffer. +func (c *X11Capturer) captureSHMInto(dst *image.RGBA) error { + if err := c.fillSHM(); err != nil { + return err + } + swizzleBGRAtoRGBA(dst.Pix, c.shmAddr[:c.w*c.h*4]) + return nil +} + +func (c *X11Capturer) fillSHM() error { + cookie := shm.GetImage(c.conn, xproto.Drawable(c.screen.Root), + 0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF, + xproto.ImageFormatZPixmap, shm.Seg(c.shmSeg), 0) + if _, err := cookie.Reply(); err != nil { + return fmt.Errorf("SHM GetImage: %w", err) + } + return nil +} + +func (c *X11Capturer) closeSHM() { + if c.useSHM { + shm.Detach(c.conn, shm.Seg(c.shmSeg)) + if err := unix.SysvShmDetach(c.shmAddr); err != nil { + log.Debugf("shmdt on close: %v", err) + } + } +} diff --git a/client/vnc/server/capture_x11_shm_stub.go b/client/vnc/server/capture_x11_shm_stub.go new file mode 100644 index 00000000000..41eff341d2c --- /dev/null +++ b/client/vnc/server/capture_x11_shm_stub.go @@ -0,0 +1,24 @@ +//go:build freebsd + +package server + +import ( + "fmt" + "image" +) + +func (c *X11Capturer) initSHM() error { + return fmt.Errorf("SysV SHM not available on this platform") +} + +func (c *X11Capturer) captureSHM() (*image.RGBA, error) { + return nil, fmt.Errorf("SHM capture not available on this platform") +} + +func (c *X11Capturer) captureSHMInto(_ *image.RGBA) error { + return fmt.Errorf("SHM capture not available on this platform") +} + +func (c *X11Capturer) closeSHM() { + // no SHM to close on this platform +} diff --git a/client/vnc/server/coalesce_test.go b/client/vnc/server/coalesce_test.go new file mode 100644 index 00000000000..f37bc9eee89 --- /dev/null +++ b/client/vnc/server/coalesce_test.go @@ -0,0 +1,75 @@ +package server + +import ( + "reflect" + "testing" +) + +func TestCoalesceRects(t *testing.T) { + cases := []struct { + name string + in [][4]int + want [][4]int + }{ + { + name: "empty", + in: nil, + want: nil, + }, + { + name: "single", + in: [][4]int{{0, 0, 64, 64}}, + want: [][4]int{{0, 0, 64, 64}}, + }, + { + name: "horizontal_run", + in: [][4]int{{0, 0, 64, 64}, {64, 0, 64, 64}, {128, 0, 64, 64}}, + want: [][4]int{{0, 0, 192, 64}}, + }, + { + name: "vertical_run", + in: [][4]int{{0, 0, 64, 64}, {0, 64, 64, 64}, {0, 128, 64, 64}}, + want: [][4]int{{0, 0, 64, 192}}, + }, + { + name: "block_2x2", + in: [][4]int{ + {0, 0, 64, 64}, {64, 0, 64, 64}, + {0, 64, 64, 64}, {64, 64, 64, 64}, + }, + want: [][4]int{{0, 0, 128, 128}}, + }, + { + name: "no_merge_gap", + in: [][4]int{{0, 0, 64, 64}, {192, 0, 64, 64}}, + want: [][4]int{{0, 0, 64, 64}, {192, 0, 64, 64}}, + }, + { + name: "two_disjoint_columns", + in: [][4]int{ + {0, 0, 64, 64}, {192, 0, 64, 64}, + {0, 64, 64, 64}, {192, 64, 64, 64}, + }, + want: [][4]int{{0, 0, 64, 128}, {192, 0, 64, 128}}, + }, + { + name: "misaligned_widths_no_vertical_merge", + in: [][4]int{ + {0, 0, 128, 64}, + {0, 64, 64, 64}, + }, + want: [][4]int{{0, 0, 128, 64}, {0, 64, 64, 64}}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := coalesceRects(tc.in) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} diff --git a/client/vnc/server/hextile_test.go b/client/vnc/server/hextile_test.go new file mode 100644 index 00000000000..69a1904d456 --- /dev/null +++ b/client/vnc/server/hextile_test.go @@ -0,0 +1,188 @@ +package server + +import ( + "image" + "testing" +) + +// roundTrip decodes an encoded Hextile rect back into pixels and checks it +// matches the source. Implements just enough of the noVNC Hextile decoder +// to validate our encoder. +func decodeHextile(t *testing.T, buf []byte, pf clientPixelFormat) *image.RGBA { + t.Helper() + if len(buf) < 12 { + t.Fatalf("buf too short: %d", len(buf)) + } + x := int(uint16(buf[0])<<8 | uint16(buf[1])) + y := int(uint16(buf[2])<<8 | uint16(buf[3])) + w := int(uint16(buf[4])<<8 | uint16(buf[5])) + h := int(uint16(buf[6])<<8 | uint16(buf[7])) + enc := uint32(buf[8])<<24 | uint32(buf[9])<<16 | uint32(buf[10])<<8 | uint32(buf[11]) + if enc != encHextile { + t.Fatalf("not hextile: %d", enc) + } + body := buf[12:] + bytesPerPixel := max(int(pf.bpp)/8, 1) + out := image.NewRGBA(image.Rect(x, y, x+w, y+h)) + + var bg, fg [3]byte + pos := 0 + readPixel := func() [3]byte { + var v uint32 + if pf.bigEndian != 0 { + for i := 0; i < bytesPerPixel; i++ { + v |= uint32(body[pos+i]) << (8 * (bytesPerPixel - 1 - i)) + } + } else { + for i := 0; i < bytesPerPixel; i++ { + v |= uint32(body[pos+i]) << (8 * i) + } + } + pos += bytesPerPixel + r := byte((v >> pf.rShift) & uint32(pf.rMax)) + g := byte((v >> pf.gShift) & uint32(pf.gMax)) + b := byte((v >> pf.bShift) & uint32(pf.bMax)) + return [3]byte{r, g, b} + } + for sy := 0; sy < h; sy += hextileSubSize { + sh := min(hextileSubSize, h-sy) + for sx := 0; sx < w; sx += hextileSubSize { + sw := min(hextileSubSize, w-sx) + flags := body[pos] + pos++ + if flags&hextileRaw != 0 { + for ry := 0; ry < sh; ry++ { + for rx := 0; rx < sw; rx++ { + px := readPixel() + i := (sy+ry)*out.Stride + (sx+rx)*4 + out.Pix[i+0] = px[0] + out.Pix[i+1] = px[1] + out.Pix[i+2] = px[2] + out.Pix[i+3] = 0xff + } + } + continue + } + if flags&hextileBackgroundSpecified != 0 { + bg = readPixel() + } + if flags&hextileForegroundSpecified != 0 { + fg = readPixel() + } + // Fill sub-tile with bg. + for ry := 0; ry < sh; ry++ { + for rx := 0; rx < sw; rx++ { + i := (sy+ry)*out.Stride + (sx+rx)*4 + out.Pix[i+0] = bg[0] + out.Pix[i+1] = bg[1] + out.Pix[i+2] = bg[2] + out.Pix[i+3] = 0xff + } + } + if flags&hextileAnySubrects == 0 { + continue + } + n := int(body[pos]) + pos++ + for k := 0; k < n; k++ { + color := fg + if flags&hextileSubrectsColoured != 0 { + color = readPixel() + } + xy := body[pos] + wh := body[pos+1] + pos += 2 + rxr := int(xy >> 4) + ryr := int(xy & 0x0f) + rwr := int(wh>>4) + 1 + rhr := int(wh&0x0f) + 1 + for ry := 0; ry < rhr; ry++ { + for rx := 0; rx < rwr; rx++ { + i := (sy+ryr+ry)*out.Stride + (sx+rxr+rx)*4 + out.Pix[i+0] = color[0] + out.Pix[i+1] = color[1] + out.Pix[i+2] = color[2] + out.Pix[i+3] = 0xff + } + } + } + } + } + return out +} + +func makeUniformImage(w, h int, r, g, b byte) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i+0] = r + img.Pix[i+1] = g + img.Pix[i+2] = b + img.Pix[i+3] = 0xff + } + return img +} + +func makeTwoColorImage(w, h int) *image.RGBA { + img := makeUniformImage(w, h, 0x10, 0x20, 0x30) + // Draw a vertical bar of fg in the middle. + fg := [3]byte{0xa0, 0xb0, 0xc0} + for y := 0; y < h; y++ { + for x := w / 4; x < w/2; x++ { + i := y*img.Stride + x*4 + img.Pix[i+0] = fg[0] + img.Pix[i+1] = fg[1] + img.Pix[i+2] = fg[2] + } + } + return img +} + +func compareImages(t *testing.T, want, got *image.RGBA) { + t.Helper() + if want.Rect != got.Rect { + t.Fatalf("rect mismatch: %v vs %v", want.Rect, got.Rect) + } + w, h := want.Rect.Dx(), want.Rect.Dy() + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := y*want.Stride + x*4 + j := y*got.Stride + x*4 + if want.Pix[i] != got.Pix[j] || want.Pix[i+1] != got.Pix[j+1] || want.Pix[i+2] != got.Pix[j+2] { + t.Fatalf("pixel mismatch at (%d,%d): want %v got %v", + x, y, want.Pix[i:i+3], got.Pix[j:j+3]) + } + } + } +} + +func TestEncodeHextileRect_Uniform(t *testing.T) { + pf := defaultClientPixelFormat() + img := makeUniformImage(64, 64, 0x33, 0x66, 0x99) + buf := encodeHextileRect(img, pf, 0, 0, 64, 64) + got := decodeHextile(t, buf, pf) + compareImages(t, img, got) +} + +func TestEncodeHextileRect_TwoColor(t *testing.T) { + pf := defaultClientPixelFormat() + img := makeTwoColorImage(64, 64) + buf := encodeHextileRect(img, pf, 0, 0, 64, 64) + got := decodeHextile(t, buf, pf) + compareImages(t, img, got) +} + +func TestEncodeHextileRect_Multicolor(t *testing.T) { + pf := defaultClientPixelFormat() + img := makeBenchImage(64, 64, 42) + buf := encodeHextileRect(img, pf, 0, 0, 64, 64) + got := decodeHextile(t, buf, pf) + compareImages(t, img, got) +} + +func TestEncodeHextileRect_NonAligned(t *testing.T) { + pf := defaultClientPixelFormat() + img := makeTwoColorImage(50, 33) // not a multiple of 16 + buf := encodeHextileRect(img, pf, 0, 0, 50, 33) + got := decodeHextile(t, buf, pf) + compareImages(t, img, got) +} diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go new file mode 100644 index 00000000000..2982c71d9bf --- /dev/null +++ b/client/vnc/server/input_darwin.go @@ -0,0 +1,613 @@ +//go:build darwin && !ios + +package server + +import ( + "fmt" + "os/exec" + "strings" + "sync" + + "github.com/ebitengine/purego" + log "github.com/sirupsen/logrus" +) + +// Core Graphics event constants. +const ( + kCGEventSourceStateCombinedSessionState int32 = 0 + + kCGEventLeftMouseDown int32 = 1 + kCGEventLeftMouseUp int32 = 2 + kCGEventRightMouseDown int32 = 3 + kCGEventRightMouseUp int32 = 4 + kCGEventMouseMoved int32 = 5 + kCGEventLeftMouseDragged int32 = 6 + kCGEventRightMouseDragged int32 = 7 + kCGEventKeyDown int32 = 10 + kCGEventKeyUp int32 = 11 + kCGEventOtherMouseDown int32 = 25 + kCGEventOtherMouseUp int32 = 26 + + kCGMouseButtonLeft int32 = 0 + kCGMouseButtonRight int32 = 1 + kCGMouseButtonCenter int32 = 2 + + kCGHIDEventTap int32 = 0 + + // IOKit power management constants. + kIOPMUserActiveLocal int32 = 0 + kIOPMAssertionLevelOn uint32 = 255 + kCFStringEncodingUTF8 uint32 = 0x08000100 +) + +var darwinInputOnce sync.Once + +var ( + cgEventSourceCreate func(int32) uintptr + cgEventCreateKeyboardEvent func(uintptr, uint16, bool) uintptr + // CGEventCreateMouseEvent takes CGPoint as two separate float64 args. + // purego can't handle array/struct types but individual float64s work. + cgEventCreateMouseEvent func(uintptr, int32, float64, float64, int32) uintptr + cgEventPost func(int32, uintptr) + + // CGEventCreateScrollWheelEvent is variadic, call via SyscallN. + cgEventCreateScrollWheelEventAddr uintptr + + axIsProcessTrusted func() bool + + // IOKit power-management bindings used to wake the display and inhibit + // idle sleep while a VNC client is driving input. + iopmAssertionDeclareUserActivity func(uintptr, int32, *uint32) int32 + iopmAssertionCreateWithName func(uintptr, uint32, uintptr, *uint32) int32 + iopmAssertionRelease func(uint32) int32 + cfStringCreateWithCString func(uintptr, string, uint32) uintptr + + // Cached CFStrings for assertion name and idle-sleep type. + pmAssertionNameCFStr uintptr + pmPreventIdleDisplayCFStr uintptr + + // Assertion IDs. userActivityID is reused across input events so repeated + // calls refresh the same assertion rather than create new ones. + pmMu sync.Mutex + userActivityID uint32 + preventSleepID uint32 + preventSleepHeld bool + preventSleepRef int // refcount across concurrent injectors/sessions + + darwinInputReady bool + darwinEventSource uintptr +) + +func initDarwinInput() { + darwinInputOnce.Do(func() { + cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + log.Debugf("load CoreGraphics for input: %v", err) + return + } + + purego.RegisterLibFunc(&cgEventSourceCreate, cg, "CGEventSourceCreate") + purego.RegisterLibFunc(&cgEventCreateKeyboardEvent, cg, "CGEventCreateKeyboardEvent") + purego.RegisterLibFunc(&cgEventCreateMouseEvent, cg, "CGEventCreateMouseEvent") + purego.RegisterLibFunc(&cgEventPost, cg, "CGEventPost") + + sym, err := purego.Dlsym(cg, "CGEventCreateScrollWheelEvent") + if err == nil { + cgEventCreateScrollWheelEventAddr = sym + } + + if ax, err := purego.Dlopen("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices", purego.RTLD_NOW|purego.RTLD_GLOBAL); err == nil { + if sym, err := purego.Dlsym(ax, "AXIsProcessTrusted"); err == nil { + purego.RegisterFunc(&axIsProcessTrusted, sym) + } + } + + initPowerAssertions() + + darwinInputReady = true + }) +} + +func initPowerAssertions() { + iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + log.Debugf("load IOKit: %v", err) + return + } + cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + log.Debugf("load CoreFoundation for power assertions: %v", err) + return + } + + purego.RegisterLibFunc(&cfStringCreateWithCString, cf, "CFStringCreateWithCString") + purego.RegisterLibFunc(&iopmAssertionDeclareUserActivity, iokit, "IOPMAssertionDeclareUserActivity") + purego.RegisterLibFunc(&iopmAssertionCreateWithName, iokit, "IOPMAssertionCreateWithName") + purego.RegisterLibFunc(&iopmAssertionRelease, iokit, "IOPMAssertionRelease") + + pmAssertionNameCFStr = cfStringCreateWithCString(0, "NetBird VNC input", kCFStringEncodingUTF8) + pmPreventIdleDisplayCFStr = cfStringCreateWithCString(0, "PreventUserIdleDisplaySleep", kCFStringEncodingUTF8) +} + +// wakeDisplay declares user activity so macOS treats the synthesized input as +// real HID activity, waking the display if it is asleep. Called on every key +// and pointer event; the kernel coalesces repeated calls cheaply. +func wakeDisplay() { + if iopmAssertionDeclareUserActivity == nil || pmAssertionNameCFStr == 0 { + return + } + pmMu.Lock() + defer pmMu.Unlock() + id := userActivityID + r := iopmAssertionDeclareUserActivity(pmAssertionNameCFStr, kIOPMUserActiveLocal, &id) + if r != 0 { + log.Tracef("IOPMAssertionDeclareUserActivity returned %d", r) + return + } + userActivityID = id +} + +// holdPreventIdleSleep creates an assertion that keeps the display from going +// idle-to-sleep while a VNC session is active. Reference-counted so multiple +// concurrent sessions don't yank the assertion when one of them releases. +func holdPreventIdleSleep() { + if iopmAssertionCreateWithName == nil || pmPreventIdleDisplayCFStr == 0 || pmAssertionNameCFStr == 0 { + return + } + pmMu.Lock() + defer pmMu.Unlock() + preventSleepRef++ + if preventSleepRef > 1 { + return + } + var id uint32 + r := iopmAssertionCreateWithName(pmPreventIdleDisplayCFStr, kIOPMAssertionLevelOn, pmAssertionNameCFStr, &id) + if r != 0 { + log.Debugf("IOPMAssertionCreateWithName returned %d", r) + // Reset the refcount on failure so a later successful hold can take it. + preventSleepRef = 0 + return + } + preventSleepID = id + preventSleepHeld = true +} + +// releasePreventIdleSleep decrements the assertion refcount and only drops +// the actual IOKit assertion on the final release. +func releasePreventIdleSleep() { + if iopmAssertionRelease == nil { + return + } + pmMu.Lock() + defer pmMu.Unlock() + if !preventSleepHeld || preventSleepRef == 0 { + return + } + preventSleepRef-- + if preventSleepRef > 0 { + return + } + if r := iopmAssertionRelease(preventSleepID); r != 0 { + log.Debugf("IOPMAssertionRelease returned %d", r) + } + preventSleepHeld = false + preventSleepID = 0 +} + +func ensureEventSource() uintptr { + if darwinEventSource != 0 { + return darwinEventSource + } + darwinEventSource = cgEventSourceCreate(kCGEventSourceStateCombinedSessionState) + return darwinEventSource +} + +// MacInputInjector injects keyboard and mouse events via Core Graphics. +type MacInputInjector struct { + lastButtons uint8 + pbcopyPath string + pbpastePath string +} + +// NewMacInputInjector creates a macOS input injector. +func NewMacInputInjector() (*MacInputInjector, error) { + initDarwinInput() + if !darwinInputReady { + return nil, fmt.Errorf("CoreGraphics not available for input injection") + } + checkMacPermissions() + + m := &MacInputInjector{} + if path, err := exec.LookPath("pbcopy"); err == nil { + m.pbcopyPath = path + } + if path, err := exec.LookPath("pbpaste"); err == nil { + m.pbpastePath = path + } + if m.pbcopyPath == "" || m.pbpastePath == "" { + log.Debugf("clipboard tools not found (pbcopy=%q, pbpaste=%q)", m.pbcopyPath, m.pbpastePath) + } + + holdPreventIdleSleep() + + log.Info("macOS input injector ready") + return m, nil +} + +// checkMacPermissions warns and opens the Privacy pane if Accessibility is +// missing. Uses AXIsProcessTrusted which returns immediately; the previous +// osascript probe blocked for 120s (AppleEvent timeout) when access was +// denied, which delayed VNC server startup past client deadlines. +func checkMacPermissions() { + if axIsProcessTrusted != nil && !axIsProcessTrusted() { + openPrivacyPane("Privacy_Accessibility") + log.Warn("Accessibility permission not granted. Input injection will not work. " + + "Opened System Settings > Privacy & Security > Accessibility; enable netbird.") + } + + log.Info("Screen Recording permission is required for screen capture. " + + "If the screen appears black, grant in System Settings > Privacy & Security > Screen Recording.") +} + +// openPrivacyPane opens the given Privacy pane in System Settings so the user +// can toggle the permission without navigating manually. +func openPrivacyPane(pane string) { + url := "x-apple.systempreferences:com.apple.preference.security?" + pane + if err := exec.Command("open", url).Start(); err != nil { + log.Debugf("open privacy pane %s: %v", pane, err) + } +} + +// InjectKey simulates a key press or release. +func (m *MacInputInjector) InjectKey(keysym uint32, down bool) { + wakeDisplay() + src := ensureEventSource() + if src == 0 { + return + } + keycode := keysymToMacKeycode(keysym) + if keycode == 0xFFFF { + return + } + event := cgEventCreateKeyboardEvent(src, keycode, down) + if event == 0 { + return + } + cgEventPost(kCGHIDEventTap, event) + cfRelease(event) +} + +// InjectPointer simulates mouse movement and button events. +func (m *MacInputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) { + wakeDisplay() + if serverW == 0 || serverH == 0 { + return + } + src := ensureEventSource() + if src == 0 { + return + } + x, y := scalePxToLogical(px, py, serverW, serverH) + m.dispatchPointer(src, buttonMask, x, y) + m.lastButtons = buttonMask +} + +// scalePxToLogical converts framebuffer coordinates (physical pixels) into +// the logical points CGEventCreateMouseEvent expects. Falls back to a 1:1 +// mapping if the display API is unavailable. +func scalePxToLogical(px, py, serverW, serverH int) (float64, float64) { + x, y := float64(px), float64(py) + if cgDisplayPixelsWide == nil || cgMainDisplayID == nil { + return x, y + } + displayID := cgMainDisplayID() + logicalW := int(cgDisplayPixelsWide(displayID)) + logicalH := int(cgDisplayPixelsHigh(displayID)) + if logicalW <= 0 || logicalH <= 0 { + return x, y + } + return float64(px) * float64(logicalW) / float64(serverW), + float64(py) * float64(logicalH) / float64(serverH) +} + +func (m *MacInputInjector) dispatchPointer(src uintptr, buttonMask uint8, x, y float64) { + leftDown := buttonMask&0x01 != 0 + rightDown := buttonMask&0x04 != 0 + middleDown := buttonMask&0x02 != 0 + m.postMoveOrDrag(src, leftDown, rightDown, x, y) + m.postButtonTransitions(src, buttonMask, x, y) + m.postScrollWheel(src, buttonMask) + _ = middleDown +} + +func (m *MacInputInjector) postMoveOrDrag(src uintptr, leftDown, rightDown bool, x, y float64) { + switch { + case leftDown: + m.postMouse(src, kCGEventLeftMouseDragged, x, y, kCGMouseButtonLeft) + case rightDown: + m.postMouse(src, kCGEventRightMouseDragged, x, y, kCGMouseButtonRight) + default: + m.postMouse(src, kCGEventMouseMoved, x, y, kCGMouseButtonLeft) + } +} + +// postButtonTransitions emits the up/down events for each button whose state +// changed against m.lastButtons. +func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, x, y float64) { + emit := func(curBit, prevBit uint8, down, up int32, button int32) { + cur := buttonMask&curBit != 0 + prev := m.lastButtons&prevBit != 0 + if cur && !prev { + m.postMouse(src, down, x, y, button) + } else if !cur && prev { + m.postMouse(src, up, x, y, button) + } + } + emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft) + emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight) + emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter) +} + +func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint8) { + if buttonMask&0x08 != 0 { + m.postScroll(src, 3) + } + if buttonMask&0x10 != 0 { + m.postScroll(src, -3) + } +} + +func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, button int32) { + if cgEventCreateMouseEvent == nil { + return + } + event := cgEventCreateMouseEvent(src, eventType, x, y, button) + if event == 0 { + return + } + cgEventPost(kCGHIDEventTap, event) + cfRelease(event) +} + +func (m *MacInputInjector) postScroll(src uintptr, deltaY int32) { + if cgEventCreateScrollWheelEventAddr == 0 { + return + } + // CGEventCreateScrollWheelEvent(source, units, wheelCount, wheel1delta) + // units=0 (pixel), wheelCount=1, wheel1delta=deltaY + // Variadic C function: pass args as uintptr via SyscallN. + r1, _, _ := purego.SyscallN(cgEventCreateScrollWheelEventAddr, + src, 0, 1, uintptr(uint32(deltaY))) + if r1 == 0 { + return + } + cgEventPost(kCGHIDEventTap, r1) + cfRelease(r1) +} + +// SetClipboard sets the macOS clipboard using pbcopy. +func (m *MacInputInjector) SetClipboard(text string) { + if m.pbcopyPath == "" { + return + } + cmd := exec.Command(m.pbcopyPath) + cmd.Stdin = strings.NewReader(text) + if err := cmd.Run(); err != nil { + log.Tracef("set clipboard via pbcopy: %v", err) + } +} + +// TypeText synthesizes the given text as keystrokes via Core Graphics. +// Used by the dashboard's Paste button so the host clipboard reaches +// the focused remote app even when the app doesn't honor pbpaste-style +// clipboard sync (e.g. login screens, locked-down apps). ASCII printable +// runes only; others are skipped. +func (m *MacInputInjector) TypeText(text string) { + wakeDisplay() + src := ensureEventSource() + if src == 0 { + return + } + const maxChars = 4096 + count := 0 + for _, r := range text { + if count >= maxChars { + break + } + count++ + typeRune(src, r) + } +} + +// typeRune emits the press/release events for a single ASCII rune, framing +// the keystroke with Shift-down/up when required by the keysym. +func typeRune(src uintptr, r rune) { + const shiftKey = uint16(0x38) // kVK_Shift + keysym, shift, ok := keysymForASCIIRune(r) + if !ok { + return + } + keycode := keysymToMacKeycode(keysym) + if keycode == 0xFFFF { + return + } + if shift { + postKey(src, shiftKey, true) + } + postKey(src, keycode, true) + postKey(src, keycode, false) + if shift { + postKey(src, shiftKey, false) + } +} + +func postKey(src uintptr, keycode uint16, down bool) { + e := cgEventCreateKeyboardEvent(src, keycode, down) + if e == 0 { + return + } + cgEventPost(kCGHIDEventTap, e) + cfRelease(e) +} + +// GetClipboard reads the macOS clipboard using pbpaste. +func (m *MacInputInjector) GetClipboard() string { + if m.pbpastePath == "" { + return "" + } + out, err := exec.Command(m.pbpastePath).Output() + if err != nil { + log.Tracef("get clipboard via pbpaste: %v", err) + return "" + } + return string(out) +} + +// Close releases the idle-sleep assertion held for the injector's lifetime. +func (m *MacInputInjector) Close() { + releasePreventIdleSleep() +} + +func keysymToMacKeycode(keysym uint32) uint16 { + if keysym >= 0x61 && keysym <= 0x7a { + return asciiToMacKey[keysym-0x61] + } + if keysym >= 0x41 && keysym <= 0x5a { + return asciiToMacKey[keysym-0x41] + } + if keysym >= 0x30 && keysym <= 0x39 { + return digitToMacKey[keysym-0x30] + } + if code, ok := specialKeyMap[keysym]; ok { + return code + } + return 0xFFFF +} + +var asciiToMacKey = [26]uint16{ + 0x00, 0x0B, 0x08, 0x02, 0x0E, 0x03, 0x05, 0x04, + 0x22, 0x26, 0x28, 0x25, 0x2E, 0x2D, 0x1F, 0x23, + 0x0C, 0x0F, 0x01, 0x11, 0x20, 0x09, 0x0D, 0x07, + 0x10, 0x06, +} + +var digitToMacKey = [10]uint16{ + 0x1D, 0x12, 0x13, 0x14, 0x15, 0x17, 0x16, 0x1A, 0x1C, 0x19, +} + +var specialKeyMap = map[uint32]uint16{ + // Whitespace and editing + 0x0020: 0x31, // space + 0xff08: 0x33, // BackSpace + 0xff09: 0x30, // Tab + 0xff0d: 0x24, // Return + 0xff1b: 0x35, // Escape + 0xffff: 0x75, // Delete (forward) + + // Navigation + 0xff50: 0x73, // Home + 0xff51: 0x7B, // Left + 0xff52: 0x7E, // Up + 0xff53: 0x7C, // Right + 0xff54: 0x7D, // Down + 0xff55: 0x74, // Page_Up + 0xff56: 0x79, // Page_Down + 0xff57: 0x77, // End + 0xff63: 0x72, // Insert (Help on Mac) + + // Modifiers + 0xffe1: 0x38, // Shift_L + 0xffe2: 0x3C, // Shift_R + 0xffe3: 0x3B, // Control_L + 0xffe4: 0x3E, // Control_R + 0xffe5: 0x39, // Caps_Lock + 0xffe9: 0x3A, // Alt_L (Option) + 0xffea: 0x3D, // Alt_R (Option) + 0xffe7: 0x37, // Meta_L (Command) + 0xffe8: 0x36, // Meta_R (Command) + 0xffeb: 0x37, // Super_L (Command) - noVNC sends this + 0xffec: 0x36, // Super_R (Command) + + // Mode_switch / ISO_Level3_Shift (sent by noVNC for macOS Option remap) + 0xff7e: 0x3A, // Mode_switch -> Option + 0xfe03: 0x3D, // ISO_Level3_Shift -> Right Option + + // Function keys + 0xffbe: 0x7A, // F1 + 0xffbf: 0x78, // F2 + 0xffc0: 0x63, // F3 + 0xffc1: 0x76, // F4 + 0xffc2: 0x60, // F5 + 0xffc3: 0x61, // F6 + 0xffc4: 0x62, // F7 + 0xffc5: 0x64, // F8 + 0xffc6: 0x65, // F9 + 0xffc7: 0x6D, // F10 + 0xffc8: 0x67, // F11 + 0xffc9: 0x6F, // F12 + 0xffca: 0x69, // F13 + 0xffcb: 0x6B, // F14 + 0xffcc: 0x71, // F15 + 0xffcd: 0x6A, // F16 + 0xffce: 0x40, // F17 + 0xffcf: 0x4F, // F18 + 0xffd0: 0x50, // F19 + 0xffd1: 0x5A, // F20 + + // Punctuation (US keyboard layout, keysym = ASCII code) + 0x002d: 0x1B, // minus - + 0x003d: 0x18, // equal = + 0x005b: 0x21, // bracketleft [ + 0x005d: 0x1E, // bracketright ] + 0x005c: 0x2A, // backslash + 0x003b: 0x29, // semicolon ; + 0x0027: 0x27, // apostrophe ' + 0x0060: 0x32, // grave ` + 0x002c: 0x2B, // comma , + 0x002e: 0x2F, // period . + 0x002f: 0x2C, // slash / + + // Shifted punctuation (noVNC sends these as separate keysyms) + 0x005f: 0x1B, // underscore _ (shift+minus) + 0x002b: 0x18, // plus + (shift+equal) + 0x007b: 0x21, // braceleft { (shift+[) + 0x007d: 0x1E, // braceright } (shift+]) + 0x007c: 0x2A, // bar | (shift+\) + 0x003a: 0x29, // colon : (shift+;) + 0x0022: 0x27, // quotedbl " (shift+') + 0x007e: 0x32, // tilde ~ (shift+`) + 0x003c: 0x2B, // less < (shift+,) + 0x003e: 0x2F, // greater > (shift+.) + 0x003f: 0x2C, // question ? (shift+/) + 0x0021: 0x12, // exclam ! (shift+1) + 0x0040: 0x13, // at @ (shift+2) + 0x0023: 0x14, // numbersign # (shift+3) + 0x0024: 0x15, // dollar $ (shift+4) + 0x0025: 0x17, // percent % (shift+5) + 0x005e: 0x16, // asciicircum ^ (shift+6) + 0x0026: 0x1A, // ampersand & (shift+7) + 0x002a: 0x1C, // asterisk * (shift+8) + 0x0028: 0x19, // parenleft ( (shift+9) + 0x0029: 0x1D, // parenright ) (shift+0) + + // Numpad + 0xffb0: 0x52, // KP_0 + 0xffb1: 0x53, // KP_1 + 0xffb2: 0x54, // KP_2 + 0xffb3: 0x55, // KP_3 + 0xffb4: 0x56, // KP_4 + 0xffb5: 0x57, // KP_5 + 0xffb6: 0x58, // KP_6 + 0xffb7: 0x59, // KP_7 + 0xffb8: 0x5B, // KP_8 + 0xffb9: 0x5C, // KP_9 + 0xffae: 0x41, // KP_Decimal + 0xffaa: 0x43, // KP_Multiply + 0xffab: 0x45, // KP_Add + 0xffad: 0x4E, // KP_Subtract + 0xffaf: 0x4B, // KP_Divide + 0xff8d: 0x4C, // KP_Enter + 0xffbd: 0x51, // KP_Equal +} + +var _ InputInjector = (*MacInputInjector)(nil) diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_unix.go new file mode 100644 index 00000000000..03c1b8997cb --- /dev/null +++ b/client/vnc/server/input_uinput_unix.go @@ -0,0 +1,500 @@ +//go:build (linux && !android) || freebsd + +package server + +import ( + "encoding/binary" + "fmt" + "sync" + "time" + "unicode" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +// /dev/uinput ioctl numbers. Computed from the kernel _IO/_IOW macros so +// we don't depend on cgo. UINPUT_IOCTL_BASE = 'U' = 0x55. +const ( + uiDevCreate = 0x5501 + uiDevDestroy = 0x5502 + // _IOW('U', 3, struct uinput_setup); uinput_setup is 92 bytes on amd64. + uiDevSetup = (1 << 30) | (92 << 16) | (0x55 << 8) | 3 + uiSetEvBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 100 + uiSetKeyBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 101 + uiSetAbsBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 103 + uinputAbsSize = 64 // legacy struct uses absmin/absmax/absfuzz/absflat[64]. +) + +// Linux input event types and key codes (linux/input-event-codes.h). +const ( + evSyn = 0x00 + evKey = 0x01 + evAbs = 0x03 + evRep = 0x14 + + synReport = 0 + + absX = 0x00 + absY = 0x01 + + btnLeft = 0x110 + btnRight = 0x111 + btnMiddle = 0x112 +) + +// inputEvent matches struct input_event for x86_64 (timeval is 16 bytes). +// Total size 24 bytes; Go's natural alignment matches the kernel layout. +type inputEvent struct { + TvSec int64 + TvUsec int64 + Type uint16 + Code uint16 + Value int32 +} + +// UInputInjector synthesizes keyboard and mouse events via /dev/uinput. +// Used as a fallback when X11 isn't running, e.g. at the kernel console +// or pre-login screen on a server without X. Requires root or +// CAP_SYS_ADMIN, which the netbird service has. +type UInputInjector struct { + mu sync.Mutex + fd int + closeOnce sync.Once + keysymToKey map[uint32]uint16 + prevButtons uint8 + screenW int + screenH int +} + +// NewUInputInjector opens /dev/uinput and registers a virtual keyboard + +// absolute pointer device sized to (w, h). The dimensions are needed +// because uinput's ABS axes don't autoscale; we always send absolute +// coordinates and let the kernel route them to the right monitor. +func NewUInputInjector(w, h int) (*UInputInjector, error) { + if w <= 0 || h <= 0 { + return nil, fmt.Errorf("invalid screen size: %dx%d", w, h) + } + fd, err := unix.Open("/dev/uinput", unix.O_WRONLY|unix.O_NONBLOCK, 0) + if err != nil { + return nil, fmt.Errorf("open /dev/uinput: %w", err) + } + + if err := setBit(fd, uiSetEvBit, evKey); err != nil { + unix.Close(fd) + return nil, err + } + if err := setBit(fd, uiSetEvBit, evAbs); err != nil { + unix.Close(fd) + return nil, err + } + if err := setBit(fd, uiSetEvBit, evSyn); err != nil { + unix.Close(fd) + return nil, err + } + // Advertise key auto-repeat so the kernel input core repeats held + // keys at the configured rate (default ~250 ms delay, ~33 ms period). + // Without this, holding Backspace etc. only deletes one character. + if err := setBit(fd, uiSetEvBit, evRep); err != nil { + unix.Close(fd) + return nil, err + } + + keymap := buildUInputKeymap() + for _, key := range keymap { + if err := setBit(fd, uiSetKeyBit, uint32(key)); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("UI_SET_KEYBIT %d: %w", key, err) + } + } + for _, btn := range []uint16{btnLeft, btnRight, btnMiddle} { + if err := setBit(fd, uiSetKeyBit, uint32(btn)); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("UI_SET_KEYBIT btn %d: %w", btn, err) + } + } + if err := setBit(fd, uiSetAbsBit, absX); err != nil { + unix.Close(fd) + return nil, err + } + if err := setBit(fd, uiSetAbsBit, absY); err != nil { + unix.Close(fd) + return nil, err + } + + if err := writeUInputUserDev(fd, w, h); err != nil { + unix.Close(fd) + return nil, err + } + if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), uiDevCreate, 0); e != 0 { + unix.Close(fd) + return nil, fmt.Errorf("UI_DEV_CREATE: %v", e) + } + // Give udev a moment to settle before sending events. + time.Sleep(50 * time.Millisecond) + + inj := &UInputInjector{ + fd: fd, + keysymToKey: keymapByKeysym(keymap), + screenW: w, + screenH: h, + } + log.Infof("uinput injector ready: %dx%d, %d keys", w, h, len(inj.keysymToKey)) + return inj, nil +} + +func setBit(fd int, op uintptr, code uint32) error { + if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), op, uintptr(code)); e != 0 { + return fmt.Errorf("ioctl 0x%x %d: %v", op, code, e) + } + return nil +} + +// writeUInputUserDev uses the legacy uinput_user_dev path (write the +// whole struct then UI_DEV_CREATE) which is universally supported on +// older and current kernels alike. uinput_user_dev is name(80) + id(8) + +// ff_effects_max(4) + absmax/absmin/absfuzz/absflat[64] = 92 + 4*64*4 = +// 1116 bytes total. +func writeUInputUserDev(fd, w, h int) error { + const sz = 80 + 8 + 4 + uinputAbsSize*4*4 + buf := make([]byte, sz) + copy(buf[0:80], []byte("netbird-vnc-uinput")) + // id: BUS_VIRTUAL=0x06, vendor=0x0001, product=0x0001, version=1. + binary.LittleEndian.PutUint16(buf[80:82], 0x06) + binary.LittleEndian.PutUint16(buf[82:84], 0x0001) + binary.LittleEndian.PutUint16(buf[84:86], 0x0001) + binary.LittleEndian.PutUint16(buf[86:88], 0x0001) + // ff_effects_max(4) at 88..92 stays zero. + // absmax[64] at 92..348: set absX/absY. + absmaxOff := 80 + 8 + 4 + absminOff := absmaxOff + uinputAbsSize*4 + binary.LittleEndian.PutUint32(buf[absmaxOff+absX*4:], uint32(w-1)) + binary.LittleEndian.PutUint32(buf[absmaxOff+absY*4:], uint32(h-1)) + binary.LittleEndian.PutUint32(buf[absminOff+absX*4:], 0) + binary.LittleEndian.PutUint32(buf[absminOff+absY*4:], 0) + if _, err := unix.Write(fd, buf); err != nil { + return fmt.Errorf("write uinput_user_dev: %w", err) + } + return nil +} + +// emit writes a single input_event to the device. Caller-locked. +func (u *UInputInjector) emit(typ, code uint16, value int32) error { + ev := inputEvent{Type: typ, Code: code, Value: value} + buf := (*[unsafe.Sizeof(inputEvent{})]byte)(unsafe.Pointer(&ev))[:] + _, err := unix.Write(u.fd, buf) + return err +} + +func (u *UInputInjector) sync() { + _ = u.emit(evSyn, synReport, 0) +} + +// InjectKey synthesizes a press or release for the given X11 keysym. +func (u *UInputInjector) InjectKey(keysym uint32, down bool) { + u.mu.Lock() + defer u.mu.Unlock() + code, ok := u.keysymToKey[keysym] + if !ok { + return + } + value := int32(0) + if down { + value = 1 + } + if err := u.emit(evKey, code, value); err != nil { + log.Tracef("uinput emit key: %v", err) + return + } + u.sync() +} + +// InjectPointer moves the absolute pointer and presses/releases buttons +// based on the RFB button mask delta against the previous mask. +func (u *UInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) { + u.mu.Lock() + defer u.mu.Unlock() + if serverW <= 1 || serverH <= 1 { + return + } + absXVal := int32(x * (u.screenW - 1) / (serverW - 1)) + absYVal := int32(y * (u.screenH - 1) / (serverH - 1)) + _ = u.emit(evAbs, absX, absXVal) + _ = u.emit(evAbs, absY, absYVal) + + type btnMap struct { + bit uint8 + key uint16 + } + for _, b := range []btnMap{ + {0x01, btnLeft}, + {0x02, btnMiddle}, + {0x04, btnRight}, + } { + pressed := buttonMask&b.bit != 0 + was := u.prevButtons&b.bit != 0 + if pressed && !was { + _ = u.emit(evKey, b.key, 1) + } else if !pressed && was { + _ = u.emit(evKey, b.key, 0) + } + } + u.prevButtons = buttonMask + u.sync() +} + +// SetClipboard is a no-op on the framebuffer console: there is no system +// clipboard daemon. Use TypeText (Paste button) to deliver host text. +func (u *UInputInjector) SetClipboard(_ string) { + // no system clipboard daemon on framebuffer console +} + +// GetClipboard returns empty: no clipboard outside X11/Wayland. +func (u *UInputInjector) GetClipboard() string { return "" } + +// TypeText synthesizes the given UTF-8 text as keystrokes. Only ASCII +// printable characters and newline are typed; other runes are skipped. +// This drives the "paste" button: with no console clipboard available, +// keystroke-by-keystroke entry is the only way to deliver a password to +// a TTY login prompt. +func (u *UInputInjector) TypeText(text string) { + u.mu.Lock() + defer u.mu.Unlock() + const maxChars = 4096 + count := 0 + for _, r := range text { + if count >= maxChars { + break + } + count++ + code, shift, ok := keyForRune(r) + if !ok { + continue + } + if shift { + _ = u.emit(evKey, keyLeftShift, 1) + } + _ = u.emit(evKey, code, 1) + _ = u.emit(evKey, code, 0) + if shift { + _ = u.emit(evKey, keyLeftShift, 0) + } + u.sync() + } +} + +// Close destroys the virtual uinput device and closes the file descriptor. +func (u *UInputInjector) Close() { + u.closeOnce.Do(func() { + u.mu.Lock() + defer u.mu.Unlock() + if u.fd >= 0 { + _, _, _ = unix.Syscall(unix.SYS_IOCTL, uintptr(u.fd), uiDevDestroy, 0) + _ = unix.Close(u.fd) + u.fd = -1 + } + }) +} + +// Linux KEY_* codes for the small set we care about. +const ( + keyEsc = 1 + keyMinus = 12 + keyEqual = 13 + keyBackspace = 14 + keyTab = 15 + keyEnter = 28 + keyLeftCtrl = 29 + keySemicolon = 39 + keyApostrophe = 40 + keyGrave = 41 + keyLeftShift = 42 + keyBackslash = 43 + keyComma = 51 + keyDot = 52 + keySlash = 53 + keyRightShift = 54 + keyLeftAlt = 56 + keySpace = 57 + keyCapsLock = 58 + keyF1 = 59 + keyLeftBracket = 26 + keyRightBracket = 27 + keyHome = 102 + keyUp = 103 + keyPageUp = 104 + keyLeft = 105 + keyRight = 106 + keyEnd = 107 + keyDown = 108 + keyPageDown = 109 + keyInsert = 110 + keyDelete = 111 + keyRightCtrl = 97 + keyRightAlt = 100 + keyLeftMeta = 125 + keyRightMeta = 126 +) + +// buildUInputKeymap returns every linux KEY_ code we want the virtual +// device to advertise during UI_SET_KEYBIT. Order doesn't matter. +func buildUInputKeymap() []uint16 { + out := make([]uint16, 0, 128) + // Letters: KEY_A=30, KEY_B=48, etc; not a clean range. The kernel's + // row-by-row layout is qwertyuiop / asdfghjkl / zxcvbnm. + letters := []uint16{ + 30, 48, 46, 32, 18, 33, 34, 35, 23, 36, 37, 38, 50, // a..m + 49, 24, 25, 16, 19, 31, 20, 22, 47, 17, 45, 21, 44, // n..z + } + out = append(out, letters...) + // Top-row digits: KEY_1..KEY_0 = 2..11. + for i := uint16(2); i <= 11; i++ { + out = append(out, i) + } + // Function keys F1..F12 = 59..68 + 87, 88. We only register F1..F12 + // which the kernel header enumerates as a contiguous block. + for i := uint16(59); i <= 68; i++ { + out = append(out, i) + } + out = append(out, 87, 88) + out = append(out, []uint16{ + keyEsc, keyMinus, keyEqual, keyBackspace, keyTab, keyEnter, + keyLeftCtrl, keyRightCtrl, keyLeftShift, keyRightShift, + keyLeftAlt, keyRightAlt, keyLeftMeta, keyRightMeta, + keySpace, keyCapsLock, + keyLeftBracket, keyRightBracket, keyBackslash, + keySemicolon, keyApostrophe, keyGrave, + keyComma, keyDot, keySlash, + keyHome, keyEnd, keyPageUp, keyPageDown, + keyUp, keyDown, keyLeft, keyRight, + keyInsert, keyDelete, + }...) + return out +} + +// keymapByKeysym maps X11 keysyms (the values our session receives over +// RFB) onto Linux KEY_ codes. Shifted ASCII keysyms (uppercase letters, +// "!@#..." etc.) map to the same scan code as their unshifted twin: the +// client also sends a separate Shift keysym (0xffe1), so the kernel +// composes the final character from the held modifier + scan code. +func keymapByKeysym(_ []uint16) map[uint32]uint16 { + letters := map[rune]uint16{ + 'a': 30, 'b': 48, 'c': 46, 'd': 32, 'e': 18, 'f': 33, 'g': 34, + 'h': 35, 'i': 23, 'j': 36, 'k': 37, 'l': 38, 'm': 50, + 'n': 49, 'o': 24, 'p': 25, 'q': 16, 'r': 19, 's': 31, 't': 20, + 'u': 22, 'v': 47, 'w': 17, 'x': 45, 'y': 21, 'z': 44, + } + m := map[uint32]uint16{ + // Digits. + '0': 11, '1': 2, '2': 3, '3': 4, '4': 5, '5': 6, '6': 7, + '7': 8, '8': 9, '9': 10, + // Shifted digits (US layout). + ')': 11, '!': 2, '@': 3, '#': 4, '$': 5, '%': 6, '^': 7, + '&': 8, '*': 9, '(': 10, + // Punctuation (US layout) and shifted twins. + ' ': keySpace, + '-': keyMinus, '_': keyMinus, + '=': keyEqual, '+': keyEqual, + '[': keyLeftBracket, '{': keyLeftBracket, + ']': keyRightBracket, '}': keyRightBracket, + '\\': keyBackslash, '|': keyBackslash, + ';': keySemicolon, ':': keySemicolon, + '\'': keyApostrophe, '"': keyApostrophe, + '`': keyGrave, '~': keyGrave, + ',': keyComma, '<': keyComma, + '.': keyDot, '>': keyDot, + '/': keySlash, '?': keySlash, + // Special keys (X11 keysyms). + 0xff08: keyBackspace, 0xff09: keyTab, 0xff0d: keyEnter, + 0xff1b: keyEsc, 0xffff: keyDelete, + 0xff50: keyHome, 0xff57: keyEnd, + 0xff51: keyLeft, 0xff52: keyUp, 0xff53: keyRight, 0xff54: keyDown, + 0xff55: keyPageUp, 0xff56: keyPageDown, 0xff63: keyInsert, + 0xffe1: keyLeftShift, 0xffe2: keyRightShift, + 0xffe3: keyLeftCtrl, 0xffe4: keyRightCtrl, + 0xffe9: keyLeftAlt, 0xffea: keyRightAlt, + 0xffeb: keyLeftMeta, 0xffec: keyRightMeta, + } + // Letters: register both lowercase and uppercase keysyms onto the same + // KEY_ code. The client sends Shift separately for uppercase. + for r, code := range letters { + m[uint32(r)] = code + m[uint32(r-'a'+'A')] = code + } + // Function keys F1..F12 (X11 keysyms 0xffbe..0xffc9 → KEY_F1..KEY_F12). + xF := uint32(0xffbe) + codes := []uint16{59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88} + for i, c := range codes { + m[xF+uint32(i)] = c + } + return m +} + +// keyForRune maps a printable rune to (keycode, needsShift). Used by +// TypeText to synthesize keystrokes for a paste payload. +func keyForRune(r rune) (uint16, bool, bool) { + if r >= 'a' && r <= 'z' { + m := map[rune]uint16{ + 'a': 30, 'b': 48, 'c': 46, 'd': 32, 'e': 18, 'f': 33, 'g': 34, + 'h': 35, 'i': 23, 'j': 36, 'k': 37, 'l': 38, 'm': 50, + 'n': 49, 'o': 24, 'p': 25, 'q': 16, 'r': 19, 's': 31, 't': 20, + 'u': 22, 'v': 47, 'w': 17, 'x': 45, 'y': 21, 'z': 44, + } + return m[r], false, true + } + if r >= 'A' && r <= 'Z' { + c, _, ok := keyForRune(unicode.ToLower(r)) + return c, true, ok + } + if r >= '0' && r <= '9' { + nums := []uint16{11, 2, 3, 4, 5, 6, 7, 8, 9, 10} + idx := int(r - '0') + if idx < 0 || idx >= len(nums) { //nolint:gosec // explicit bound disarms G602 + return 0, false, false + } + return nums[idx], false, true + } + if r == '\n' || r == '\r' { + return keyEnter, false, true + } + if k, ok := punctUnshifted[r]; ok { + return k, false, true + } + if k, ok := punctShifted[r]; ok { + return k, true, true + } + return 0, false, false +} + +// punctUnshifted maps ASCII punctuation that needs no Shift to its uinput +// KEY_* code. Split out of keyForRune's switch to keep the function's +// cognitive complexity below Sonar's threshold. +var punctUnshifted = map[rune]uint16{ + ' ': keySpace, + '\t': keyTab, + '-': keyMinus, + '=': keyEqual, + '[': keyLeftBracket, + ']': keyRightBracket, + '\\': keyBackslash, + ';': keySemicolon, + '\'': keyApostrophe, + '`': keyGrave, + ',': keyComma, + '.': keyDot, + '/': keySlash, +} + +// punctShifted maps ASCII punctuation that requires Shift to its base KEY_* +// code; the caller adds the shift modifier itself. +var punctShifted = map[rune]uint16{ + '!': 2, '@': 3, '#': 4, '$': 5, '%': 6, '^': 7, '&': 8, '*': 9, + '(': 10, ')': 11, + '_': keyMinus, '+': keyEqual, + '{': keyLeftBracket, '}': keyRightBracket, '|': keyBackslash, + ':': keySemicolon, '"': keyApostrophe, '~': keyGrave, + '<': keyComma, '>': keyDot, '?': keySlash, +} + +var _ InputInjector = (*UInputInjector)(nil) diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go new file mode 100644 index 00000000000..85e7a00bb93 --- /dev/null +++ b/client/vnc/server/input_windows.go @@ -0,0 +1,500 @@ +//go:build windows + +package server + +import ( + "runtime" + "sync" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +var ( + procOpenEventW = kernel32.NewProc("OpenEventW") + procSendInput = user32.NewProc("SendInput") + procVkKeyScanA = user32.NewProc("VkKeyScanA") +) + +const eventModifyState = 0x0002 + +const ( + inputMouse = 0 + inputKeyboard = 1 + + mouseeventfMove = 0x0001 + mouseeventfLeftDown = 0x0002 + mouseeventfLeftUp = 0x0004 + mouseeventfRightDown = 0x0008 + mouseeventfRightUp = 0x0010 + mouseeventfMiddleDown = 0x0020 + mouseeventfMiddleUp = 0x0040 + mouseeventfWheel = 0x0800 + mouseeventfAbsolute = 0x8000 + + wheelDelta = 120 + + keyeventfKeyUp = 0x0002 + keyeventfUnicode = 0x0004 + keyeventfScanCode = 0x0008 +) + +// winlogonDesktopName is the name of the Windows secure desktop that hosts the +// logon UI, Ctrl+Alt+Del screen, UAC prompts, and credential dialogs. Its +// clipboard is isolated from the interactive Default desktop, so pasting via +// the clipboard API does not work there. We fall back to synthesizing the +// text as Unicode keystrokes. +const winlogonDesktopName = "Winlogon" + +// maxTypedClipboardChars caps the number of characters we will synthesize as +// keystrokes when falling back on the Winlogon desktop. Passwords are short; +// a huge clipboard getting typed into the login screen would be surprising. +const maxTypedClipboardChars = 4096 + +type mouseInput struct { + Dx int32 + Dy int32 + MouseData uint32 + DwFlags uint32 + Time uint32 + DwExtraInfo uintptr +} + +type keybdInput struct { + WVk uint16 + WScan uint16 + DwFlags uint32 + Time uint32 + DwExtraInfo uintptr + _ [8]byte +} + +type inputUnion [32]byte + +type winInput struct { + Type uint32 + _ [4]byte + Data inputUnion +} + +func sendMouseInput(flags uint32, dx, dy int32, mouseData uint32) { + mi := mouseInput{ + Dx: dx, + Dy: dy, + MouseData: mouseData, + DwFlags: flags, + } + inp := winInput{Type: inputMouse} + copy(inp.Data[:], (*[unsafe.Sizeof(mi)]byte)(unsafe.Pointer(&mi))[:]) + r, _, err := procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp)) + if r == 0 { + log.Tracef("SendInput(mouse flags=0x%x): %v", flags, err) + } +} + +func sendKeyInput(vk uint16, scanCode uint16, flags uint32) { + ki := keybdInput{ + WVk: vk, + WScan: scanCode, + DwFlags: flags, + } + inp := winInput{Type: inputKeyboard} + copy(inp.Data[:], (*[unsafe.Sizeof(ki)]byte)(unsafe.Pointer(&ki))[:]) + r, _, err := procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp)) + if r == 0 { + log.Tracef("SendInput(key vk=0x%x): %v", vk, err) + } +} + +const sasEventName = `Global\NetBirdVNC_SAS` + +type inputCmd struct { + isKey bool + isClipboard bool + isType bool + keysym uint32 + down bool + buttonMask uint8 + x, y int + serverW int + serverH int + clipText string +} + +// WindowsInputInjector delivers input events from a dedicated OS thread that +// calls switchToInputDesktop before each injection. SendInput targets the +// calling thread's desktop, so the injection thread must be on the same +// desktop the user sees. +type WindowsInputInjector struct { + ch chan inputCmd + closed chan struct{} + closeOnce sync.Once + prevButtonMask uint8 + ctrlDown bool + altDown bool +} + +// NewWindowsInputInjector creates a desktop-aware input injector. +func NewWindowsInputInjector() *WindowsInputInjector { + w := &WindowsInputInjector{ + ch: make(chan inputCmd, 64), + closed: make(chan struct{}), + } + go w.loop() + return w +} + +// Close stops the injector loop. Safe to call multiple times. Subsequent +// Inject*/SetClipboard/TypeText calls become no-ops; we use a separate +// signal channel rather than closing ch so late senders can't panic. +func (w *WindowsInputInjector) Close() { + w.closeOnce.Do(func() { + close(w.closed) + }) +} + +// tryEnqueue posts a command unless the injector is closed or the channel is +// full. Non-blocking so callers (RFB read loop) never stall. +func (w *WindowsInputInjector) tryEnqueue(cmd inputCmd) { + select { + case <-w.closed: + return + default: + } + select { + case w.ch <- cmd: + default: + } +} + +func (w *WindowsInputInjector) loop() { + runtime.LockOSThread() + + for { + select { + case <-w.closed: + return + case cmd := <-w.ch: + w.dispatch(cmd) + } + } +} + +func (w *WindowsInputInjector) dispatch(cmd inputCmd) { + // Switch to the current input desktop so SendInput and the clipboard + // API target the desktop the user sees. The returned name tells us + // whether we are on the secure Winlogon desktop. + _, _ = switchToInputDesktop() + + switch { + case cmd.isClipboard: + w.doSetClipboard(cmd.clipText) + case cmd.isType: + w.typeUnicodeText(cmd.clipText) + case cmd.isKey: + w.doInjectKey(cmd.keysym, cmd.down) + default: + w.doInjectPointer(cmd.buttonMask, cmd.x, cmd.y, cmd.serverW, cmd.serverH) + } +} + +// InjectKey queues a key event for injection on the input desktop thread. +func (w *WindowsInputInjector) InjectKey(keysym uint32, down bool) { + w.tryEnqueue(inputCmd{isKey: true, keysym: keysym, down: down}) +} + +// InjectPointer queues a pointer event for injection on the input desktop +// thread. Pointer events coalesce: when the channel is full (slow desktop +// switch, hung SendInput), drop the new sample so the read loop never +// blocks. The next mouse event carries fresher position anyway. +func (w *WindowsInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) { + w.tryEnqueue(inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH}) +} + +func (w *WindowsInputInjector) doInjectKey(keysym uint32, down bool) { + switch keysym { + case 0xffe3, 0xffe4: + w.ctrlDown = down + case 0xffe9, 0xffea: + w.altDown = down + } + + if (keysym == 0xff9f || keysym == 0xffff) && w.ctrlDown && w.altDown && down { + signalSAS() + return + } + + vk, _, extended := keysym2VK(keysym) + if vk == 0 { + return + } + var flags uint32 + if !down { + flags |= keyeventfKeyUp + } + if extended { + flags |= keyeventfScanCode + } + sendKeyInput(vk, 0, flags) +} + +// signalSAS signals the SAS named event. A listener in Session 0 +// (startSASListener) calls SendSAS to trigger the Secure Attention Sequence. +func signalSAS() { + namePtr, err := windows.UTF16PtrFromString(sasEventName) + if err != nil { + log.Warnf("SAS UTF16: %v", err) + return + } + h, _, lerr := procOpenEventW.Call( + uintptr(eventModifyState), + 0, + uintptr(unsafe.Pointer(namePtr)), + ) + if h == 0 { + log.Warnf("OpenEvent(%s): %v", sasEventName, lerr) + return + } + ev := windows.Handle(h) + defer windows.CloseHandle(ev) + if err := windows.SetEvent(ev); err != nil { + log.Warnf("SetEvent SAS: %v", err) + } else { + log.Info("SAS event signaled") + } +} + +func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, serverH int) { + if serverW == 0 || serverH == 0 { + return + } + + absX := int32(x * 65535 / serverW) + absY := int32(y * 65535 / serverH) + + sendMouseInput(mouseeventfMove|mouseeventfAbsolute, absX, absY, 0) + + changed := buttonMask ^ w.prevButtonMask + w.prevButtonMask = buttonMask + + type btnMap struct { + bit uint8 + down uint32 + up uint32 + } + buttons := [...]btnMap{ + {0x01, mouseeventfLeftDown, mouseeventfLeftUp}, + {0x02, mouseeventfMiddleDown, mouseeventfMiddleUp}, + {0x04, mouseeventfRightDown, mouseeventfRightUp}, + } + for _, b := range buttons { + if changed&b.bit == 0 { + continue + } + var flags uint32 + if buttonMask&b.bit != 0 { + flags = b.down + } else { + flags = b.up + } + sendMouseInput(flags|mouseeventfAbsolute, absX, absY, 0) + } + + negWheelDelta := ^uint32(wheelDelta - 1) + if changed&0x08 != 0 && buttonMask&0x08 != 0 { + sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, wheelDelta) + } + if changed&0x10 != 0 && buttonMask&0x10 != 0 { + sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, negWheelDelta) + } +} + +// keysym2VK converts an X11 keysym to a Windows virtual key code. +func keysym2VK(keysym uint32) (vk uint16, scan uint16, extended bool) { + if keysym >= 0x20 && keysym <= 0x7e { + r, _, _ := procVkKeyScanA.Call(uintptr(keysym)) + vk = uint16(r & 0xff) + return + } + + if keysym >= 0xffbe && keysym <= 0xffc9 { + vk = uint16(0x70 + keysym - 0xffbe) + return + } + + switch keysym { + case 0xff08: + vk = 0x08 // Backspace + case 0xff09: + vk = 0x09 // Tab + case 0xff0d: + vk = 0x0d // Return + case 0xff1b: + vk = 0x1b // Escape + case 0xff63: + vk, extended = 0x2d, true // Insert + case 0xff9f, 0xffff: + vk, extended = 0x2e, true // Delete + case 0xff50: + vk, extended = 0x24, true // Home + case 0xff57: + vk, extended = 0x23, true // End + case 0xff55: + vk, extended = 0x21, true // PageUp + case 0xff56: + vk, extended = 0x22, true // PageDown + case 0xff51: + vk, extended = 0x25, true // Left + case 0xff52: + vk, extended = 0x26, true // Up + case 0xff53: + vk, extended = 0x27, true // Right + case 0xff54: + vk, extended = 0x28, true // Down + case 0xffe1, 0xffe2: + vk = 0x10 // Shift + case 0xffe3, 0xffe4: + vk = 0x11 // Control + case 0xffe9, 0xffea: + vk = 0x12 // Alt + case 0xffe5: + vk = 0x14 // CapsLock + case 0xffe7, 0xffeb: + vk, extended = 0x5B, true // Meta_L / Super_L -> Left Windows + case 0xffe8, 0xffec: + vk, extended = 0x5C, true // Meta_R / Super_R -> Right Windows + case 0xff61: + vk = 0x2c // PrintScreen + case 0xff13: + vk = 0x13 // Pause + case 0xff14: + vk = 0x91 // ScrollLock + } + return +} + +var ( + procOpenClipboard = user32.NewProc("OpenClipboard") + procCloseClipboard = user32.NewProc("CloseClipboard") + procEmptyClipboard = user32.NewProc("EmptyClipboard") + procSetClipboardData = user32.NewProc("SetClipboardData") + procGetClipboardData = user32.NewProc("GetClipboardData") + procIsClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable") + + procGlobalAlloc = kernel32.NewProc("GlobalAlloc") + procGlobalLock = kernel32.NewProc("GlobalLock") + procGlobalUnlock = kernel32.NewProc("GlobalUnlock") +) + +const ( + cfUnicodeText = 13 + gmemMoveable = 0x0002 +) + +// SetClipboard queues a request to update the Windows clipboard with the +// given UTF-8 text. The work runs on the input thread so it follows the +// current input desktop. Secure desktops (Winlogon, UAC) have isolated +// clipboards we cannot reach, so the call is a no-op there; use TypeText +// to enter text into a secure desktop instead. +func (w *WindowsInputInjector) SetClipboard(text string) { + w.tryEnqueue(inputCmd{isClipboard: true, clipText: text}) +} + +// TypeText queues a request to synthesize the given text as Unicode +// keystrokes on the current input desktop. Targets the secure desktop +// when the user is on Winlogon/UAC, where the clipboard is unreachable. +func (w *WindowsInputInjector) TypeText(text string) { + w.tryEnqueue(inputCmd{isType: true, clipText: text}) +} + +func (w *WindowsInputInjector) doSetClipboard(text string) { + utf16, err := windows.UTF16FromString(text) + if err != nil { + log.Tracef("clipboard UTF16 encode: %v", err) + return + } + + size := uintptr(len(utf16) * 2) + hMem, _, _ := procGlobalAlloc.Call(gmemMoveable, size) + if hMem == 0 { + log.Tracef("GlobalAlloc for clipboard: allocation returned nil") + return + } + + ptr, _, _ := procGlobalLock.Call(hMem) + if ptr == 0 { + log.Tracef("GlobalLock for clipboard: lock returned nil") + return + } + copy(unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), len(utf16)), utf16) + _, _, _ = procGlobalUnlock.Call(hMem) + + r, _, lerr := procOpenClipboard.Call(0) + if r == 0 { + log.Tracef("OpenClipboard: %v", lerr) + return + } + defer logCleanupCall("CloseClipboard", procCloseClipboard) + + _, _, _ = procEmptyClipboard.Call() + r, _, lerr = procSetClipboardData.Call(cfUnicodeText, hMem) + if r == 0 { + log.Tracef("SetClipboardData: %v", lerr) + } +} + +// typeUnicodeText synthesizes the given text as Unicode keystrokes via +// SendInput+KEYEVENTF_UNICODE. Used on the Winlogon secure desktop where the +// clipboard is isolated: this lets a VNC client paste a password into the +// login or credential prompt by sending ClientCutText. +func (w *WindowsInputInjector) typeUnicodeText(text string) { + utf16, err := windows.UTF16FromString(text) + if err != nil { + log.Tracef("clipboard UTF16 encode: %v", err) + return + } + if len(utf16) > 0 && utf16[len(utf16)-1] == 0 { + utf16 = utf16[:len(utf16)-1] + } + if len(utf16) > maxTypedClipboardChars { + log.Warnf("clipboard paste on Winlogon truncated to %d chars", maxTypedClipboardChars) + utf16 = utf16[:maxTypedClipboardChars] + } + for _, c := range utf16 { + sendKeyInput(0, c, keyeventfUnicode) + sendKeyInput(0, c, keyeventfUnicode|keyeventfKeyUp) + } +} + +// GetClipboard reads the Windows clipboard as UTF-8 text. +func (w *WindowsInputInjector) GetClipboard() string { + r, _, _ := procIsClipboardFormatAvailable.Call(cfUnicodeText) + if r == 0 { + return "" + } + + r, _, lerr := procOpenClipboard.Call(0) + if r == 0 { + log.Tracef("OpenClipboard for read: %v", lerr) + return "" + } + defer logCleanupCall("CloseClipboard", procCloseClipboard) + + hData, _, _ := procGetClipboardData.Call(cfUnicodeText) + if hData == 0 { + return "" + } + + ptr, _, _ := procGlobalLock.Call(hData) + if ptr == 0 { + return "" + } + defer logCleanupCallArgs("GlobalUnlock", procGlobalUnlock, hData) + + return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr))) +} + +var _ InputInjector = (*WindowsInputInjector)(nil) + +var _ ScreenCapturer = (*DesktopCapturer)(nil) diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go new file mode 100644 index 00000000000..ca1ca30a1c3 --- /dev/null +++ b/client/vnc/server/input_x11.go @@ -0,0 +1,283 @@ +//go:build (linux && !android) || freebsd + +package server + +import ( + "fmt" + "os" + "os/exec" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/jezek/xgb" + "github.com/jezek/xgb/xproto" + "github.com/jezek/xgb/xtest" +) + +// X11InputInjector injects keyboard and mouse events via the XTest extension. +type X11InputInjector struct { + conn *xgb.Conn + root xproto.Window + screen *xproto.ScreenInfo + display string + keysymMap map[uint32]byte + lastButtons uint8 + clipboardTool string + clipboardToolName string +} + +// NewX11InputInjector connects to the X11 display and initializes XTest. +func NewX11InputInjector(display string) (*X11InputInjector, error) { + detectX11Display() + + if display == "" { + display = os.Getenv("DISPLAY") + } + if display == "" { + return nil, fmt.Errorf("DISPLAY not set and no Xorg process found") + } + + conn, err := xgb.NewConnDisplay(display) + if err != nil { + return nil, fmt.Errorf("connect to X11 display %s: %w", display, err) + } + + if err := xtest.Init(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("init XTest extension: %w", err) + } + + setup := xproto.Setup(conn) + if len(setup.Roots) == 0 { + conn.Close() + return nil, fmt.Errorf("no X11 screens") + } + screen := setup.Roots[0] + + inj := &X11InputInjector{ + conn: conn, + root: screen.Root, + screen: &screen, + display: display, + } + inj.cacheKeyboardMapping() + inj.resolveClipboardTool() + + log.Infof("X11 input injector ready (display=%s)", display) + return inj, nil +} + +// InjectKey simulates a key press or release. keysym is an X11 KeySym. +func (x *X11InputInjector) InjectKey(keysym uint32, down bool) { + keycode := x.keysymToKeycode(keysym) + if keycode == 0 { + return + } + + var eventType byte + if down { + eventType = xproto.KeyPress + } else { + eventType = xproto.KeyRelease + } + + xtest.FakeInput(x.conn, eventType, keycode, 0, x.root, 0, 0, 0) +} + +// InjectPointer simulates mouse movement and button events. +func (x *X11InputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) { + if serverW == 0 || serverH == 0 { + return + } + + // Scale to actual screen coordinates. + screenW := int(x.screen.WidthInPixels) + screenH := int(x.screen.HeightInPixels) + absX := px * screenW / serverW + absY := py * screenH / serverH + + // Move pointer. + xtest.FakeInput(x.conn, xproto.MotionNotify, 0, 0, x.root, int16(absX), int16(absY), 0) + + // Handle button events. RFB button mask: bit0=left, bit1=middle, bit2=right, + // bit3=scrollUp, bit4=scrollDown. X11 buttons: 1=left, 2=middle, 3=right, + // 4=scrollUp, 5=scrollDown. + type btnMap struct { + rfbBit uint8 + x11Btn byte + } + buttons := [...]btnMap{ + {0x01, 1}, // left + {0x02, 2}, // middle + {0x04, 3}, // right + {0x08, 4}, // scroll up + {0x10, 5}, // scroll down + } + + for _, b := range buttons { + pressed := buttonMask&b.rfbBit != 0 + wasPressed := x.lastButtons&b.rfbBit != 0 + if b.x11Btn >= 4 { + // Scroll: send press+release on each scroll event. + if pressed { + xtest.FakeInput(x.conn, xproto.ButtonPress, b.x11Btn, 0, x.root, 0, 0, 0) + xtest.FakeInput(x.conn, xproto.ButtonRelease, b.x11Btn, 0, x.root, 0, 0, 0) + } + } else { + if pressed && !wasPressed { + xtest.FakeInput(x.conn, xproto.ButtonPress, b.x11Btn, 0, x.root, 0, 0, 0) + } else if !pressed && wasPressed { + xtest.FakeInput(x.conn, xproto.ButtonRelease, b.x11Btn, 0, x.root, 0, 0, 0) + } + } + } + x.lastButtons = buttonMask +} + +// cacheKeyboardMapping fetches the X11 keyboard mapping once and stores it +// as a keysym-to-keycode map, avoiding a round-trip per keystroke. +func (x *X11InputInjector) cacheKeyboardMapping() { + setup := xproto.Setup(x.conn) + minKeycode := setup.MinKeycode + maxKeycode := setup.MaxKeycode + + reply, err := xproto.GetKeyboardMapping(x.conn, minKeycode, + byte(maxKeycode-minKeycode+1)).Reply() + if err != nil { + log.Debugf("cache keyboard mapping: %v", err) + x.keysymMap = make(map[uint32]byte) + return + } + + m := make(map[uint32]byte, int(maxKeycode-minKeycode+1)*int(reply.KeysymsPerKeycode)) + keysymsPerKeycode := int(reply.KeysymsPerKeycode) + for i := int(minKeycode); i <= int(maxKeycode); i++ { + offset := (i - int(minKeycode)) * keysymsPerKeycode + for j := 0; j < keysymsPerKeycode; j++ { + ks := uint32(reply.Keysyms[offset+j]) + if ks != 0 { + if _, exists := m[ks]; !exists { + m[ks] = byte(i) + } + } + } + } + x.keysymMap = m +} + +// keysymToKeycode looks up a cached keysym-to-keycode mapping. +// Returns 0 if the keysym is not mapped. +func (x *X11InputInjector) keysymToKeycode(keysym uint32) byte { + return x.keysymMap[keysym] +} + +// SetClipboard sets the X11 clipboard using xclip or xsel. +func (x *X11InputInjector) SetClipboard(text string) { + if x.clipboardTool == "" { + return + } + + var cmd *exec.Cmd + if x.clipboardToolName == "xclip" { + cmd = exec.Command(x.clipboardTool, "-selection", "clipboard") + } else { + cmd = exec.Command(x.clipboardTool, "--clipboard", "--input") + } + cmd.Env = x.clipboardEnv() + cmd.Stdin = strings.NewReader(text) + if err := cmd.Run(); err != nil { + log.Debugf("set clipboard via %s: %v", x.clipboardToolName, err) + } +} + +// TypeText synthesizes the given text as keystrokes via XTest. We can +// no longer just stuff the host clipboard with xclip and expect Ctrl+V +// to do the rest, because the Paste button is also used at places where +// the focused application isn't a clipboard-aware one (e.g. a TTY login +// in an X11 session, an SDDM/GDM password field that ignores XSelection, +// or a kiosk app). Typing keystrokes covers all of those. +// +// Limitation: only ASCII printable characters are typed. Non-ASCII runes +// are skipped: a paste workflow for them needs Wayland-aware text input +// or layout introspection that we don't have. +func (x *X11InputInjector) TypeText(text string) { + const maxChars = 4096 + count := 0 + for _, r := range text { + if count >= maxChars { + break + } + count++ + keysym, shift, ok := keysymForASCIIRune(r) + if !ok { + continue + } + keycode := x.keysymToKeycode(keysym) + if keycode == 0 { + continue + } + var shiftCode byte + if shift { + shiftCode = x.keysymToKeycode(0xffe1) // Shift_L + if shiftCode != 0 { + xtest.FakeInput(x.conn, xproto.KeyPress, shiftCode, 0, x.root, 0, 0, 0) + } + } + xtest.FakeInput(x.conn, xproto.KeyPress, keycode, 0, x.root, 0, 0, 0) + xtest.FakeInput(x.conn, xproto.KeyRelease, keycode, 0, x.root, 0, 0, 0) + if shift && shiftCode != 0 { + xtest.FakeInput(x.conn, xproto.KeyRelease, shiftCode, 0, x.root, 0, 0, 0) + } + } +} + +func (x *X11InputInjector) resolveClipboardTool() { + for _, name := range []string{"xclip", "xsel"} { + path, err := exec.LookPath(name) + if err == nil { + x.clipboardTool = path + x.clipboardToolName = name + log.Debugf("clipboard tool resolved to %s", path) + return + } + } + log.Debugf("no clipboard tool (xclip/xsel) found, clipboard sync disabled") +} + +// GetClipboard reads the X11 clipboard using xclip or xsel. +func (x *X11InputInjector) GetClipboard() string { + if x.clipboardTool == "" { + return "" + } + + var cmd *exec.Cmd + if x.clipboardToolName == "xclip" { + cmd = exec.Command(x.clipboardTool, "-selection", "clipboard", "-o") + } else { + cmd = exec.Command(x.clipboardTool, "--clipboard", "--output") + } + cmd.Env = x.clipboardEnv() + out, err := cmd.Output() + if err != nil { + log.Tracef("get clipboard via %s: %v", x.clipboardToolName, err) + return "" + } + return string(out) +} + +func (x *X11InputInjector) clipboardEnv() []string { + env := []string{"DISPLAY=" + x.display} + if auth := os.Getenv("XAUTHORITY"); auth != "" { + env = append(env, "XAUTHORITY="+auth) + } + return env +} + +// Close releases X11 resources. +func (x *X11InputInjector) Close() { + x.conn.Close() +} + +var _ InputInjector = (*X11InputInjector)(nil) +var _ ScreenCapturer = (*X11Poller)(nil) diff --git a/client/vnc/server/keysym_typetext.go b/client/vnc/server/keysym_typetext.go new file mode 100644 index 00000000000..e74c23967fd --- /dev/null +++ b/client/vnc/server/keysym_typetext.go @@ -0,0 +1,71 @@ +package server + +// keysymForASCIIRune maps an ASCII rune to (X11 keysym for the unshifted +// version, needsShift). Used by TypeText implementations on each platform +// so the caller can explicitly press Shift instead of relying on the +// server-side modifier state. Returns ok=false for runes outside the +// supported set; non-ASCII text is dropped by TypeText. +func keysymForASCIIRune(r rune) (uint32, bool, bool) { + if r >= 'a' && r <= 'z' { + return uint32(r), false, true + } + if r >= 'A' && r <= 'Z' { + return uint32(r - 'A' + 'a'), true, true + } + if r >= '0' && r <= '9' { + return uint32(r), false, true + } + switch r { + case ' ': + return 0x20, false, true + case '\n', '\r': + return 0xff0d, false, true // Return + case '\t': + return 0xff09, false, true // Tab + case '-', '=', '[', ']', '\\', ';', '\'', '`', ',', '.', '/': + return uint32(r), false, true + case '!': + return '1', true, true + case '@': + return '2', true, true + case '#': + return '3', true, true + case '$': + return '4', true, true + case '%': + return '5', true, true + case '^': + return '6', true, true + case '&': + return '7', true, true + case '*': + return '8', true, true + case '(': + return '9', true, true + case ')': + return '0', true, true + case '_': + return '-', true, true + case '+': + return '=', true, true + case '{': + return '[', true, true + case '}': + return ']', true, true + case '|': + return '\\', true, true + case ':': + return ';', true, true + case '"': + return '\'', true, true + case '~': + return '`', true, true + case '<': + return ',', true, true + case '>': + return '.', true, true + case '?': + return '/', true, true + } + return 0, false, false +} diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go new file mode 100644 index 00000000000..bd7684eb997 --- /dev/null +++ b/client/vnc/server/rfb.go @@ -0,0 +1,905 @@ +package server + +import ( + "bytes" + "compress/zlib" + "crypto/des" //nolint:gosec // RFB protocol-defined DES challenge/response; not used for confidentiality + "encoding/binary" + "fmt" + "image" + "image/jpeg" + "unsafe" + + log "github.com/sirupsen/logrus" +) + +// rect describes a rectangle on the framebuffer in pixels. +type rect struct { + x, y, w, h int +} + +const ( + rfbProtocolVersion = "RFB 003.008\n" + + secNone = 1 + secVNCAuth = 2 + + // Client message types. + clientSetPixelFormat = 0 + clientSetEncodings = 2 + clientFramebufferUpdateRequest = 3 + clientKeyEvent = 4 + clientPointerEvent = 5 + clientCutText = 6 + + // clientNetbirdTypeText is a NetBird-specific message that asks the + // server to synthesize the given text as keystrokes regardless of the + // active desktop. Used by the dashboard's Paste button to push host + // clipboard content into a Windows secure desktop (Winlogon, UAC), + // where the OS clipboard is isolated. Format mirrors clientCutText: + // 1-byte message type + 3-byte padding + 4-byte length + text bytes. + // The opcode is in the vendor-specific range (>=128). + clientNetbirdTypeText = 250 + + // Server message types. + serverFramebufferUpdate = 0 + serverCutText = 3 + + // Encoding types. + encRaw = 0 + encHextile = 5 + encZlib = 6 + encTight = 7 + + // Tight compression-control byte top nibble. Stream-reset bits 0-3 + // (one per zlib stream) are unused while we run a single stream. + tightFillSubenc = 0x80 + tightJPEGSubenc = 0x90 + tightBasicFilter = 0x40 // Bit 6 set = explicit filter byte follows. + tightFilterCopy = 0x00 // No-op filter, raw pixel stream. + + // JPEG quality used by the Tight encoder. 70 is a reasonable speed/ + // quality knee; bandwidth roughly halves vs raw RGB while staying + // visually clean for typical desktop content. Large rects (e.g. a + // fullscreen video region) drop to a lower quality so the encoder + // keeps up at 30+ fps; the visual hit is small for moving content. + tightJPEGQuality = 70 + tightJPEGQualityMedium = 55 + tightJPEGQualityLarge = 40 + tightJPEGMediumPixels = 800 * 600 // ≈ SVGA, applies medium tier + tightJPEGLargePixels = 1280 * 720 // ≈ 720p, applies large tier + // Minimum rect area before we consider JPEG. Below this, header + // overhead dominates and Basic+zlib wins. + tightJPEGMinArea = 4096 // 64×64 ≈ 1 tile + // Distinct-colour cap below which we still prefer Basic+zlib (text, + // UI). Sampled, not exhaustive: cheap to compute, good enough. + tightJPEGMinColors = 64 + + // Hextile subencoding flags (a bitmask in the first byte of each sub-tile). + hextileRaw = 0x01 + hextileBackgroundSpecified = 0x02 + hextileForegroundSpecified = 0x04 + hextileAnySubrects = 0x08 + hextileSubrectsColoured = 0x10 + + // Hextile sub-tile size per RFB spec. + hextileSubSize = 16 +) + +// serverPixelFormat is the default pixel format advertised by the server: +// 32bpp RGBA, big-endian, true-colour, 8 bits per channel. +var serverPixelFormat = [16]byte{ + 32, // bits-per-pixel + 24, // depth + 1, // big-endian-flag + 1, // true-colour-flag + 0, 255, // red-max + 0, 255, // green-max + 0, 255, // blue-max + 16, // red-shift + 8, // green-shift + 0, // blue-shift + 0, 0, 0, // padding +} + +// clientPixelFormat holds the negotiated pixel format from the client. +type clientPixelFormat struct { + bpp uint8 + bigEndian uint8 + rMax uint16 + gMax uint16 + bMax uint16 + rShift uint8 + gShift uint8 + bShift uint8 +} + +func defaultClientPixelFormat() clientPixelFormat { + return clientPixelFormat{ + bpp: serverPixelFormat[0], + bigEndian: serverPixelFormat[2], + rMax: binary.BigEndian.Uint16(serverPixelFormat[4:6]), + gMax: binary.BigEndian.Uint16(serverPixelFormat[6:8]), + bMax: binary.BigEndian.Uint16(serverPixelFormat[8:10]), + rShift: serverPixelFormat[10], + gShift: serverPixelFormat[11], + bShift: serverPixelFormat[12], + } +} + +func parsePixelFormat(pf []byte) clientPixelFormat { + return clientPixelFormat{ + bpp: pf[0], + bigEndian: pf[2], + rMax: binary.BigEndian.Uint16(pf[4:6]), + gMax: binary.BigEndian.Uint16(pf[6:8]), + bMax: binary.BigEndian.Uint16(pf[8:10]), + rShift: pf[10], + gShift: pf[11], + bShift: pf[12], + } +} + +// encodeRawRect encodes a framebuffer region as a raw RFB rectangle. +// The returned buffer includes the FramebufferUpdate header (1 rectangle). +func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte { + bytesPerPixel := max(int(pf.bpp)/8, 1) + + pixelBytes := w * h * bytesPerPixel + buf := make([]byte, 4+12+pixelBytes) + + // FramebufferUpdate header. + buf[0] = serverFramebufferUpdate + buf[1] = 0 // padding + binary.BigEndian.PutUint16(buf[2:4], 1) + + // Rectangle header. + binary.BigEndian.PutUint16(buf[4:6], uint16(x)) + binary.BigEndian.PutUint16(buf[6:8], uint16(y)) + binary.BigEndian.PutUint16(buf[8:10], uint16(w)) + binary.BigEndian.PutUint16(buf[10:12], uint16(h)) + binary.BigEndian.PutUint32(buf[12:16], uint32(encRaw)) + + writePixels(buf[16:], img, pf, rect{x, y, w, h}, bytesPerPixel) + return buf +} + +// writePixels writes a rectangle of img into dst in the client's requested +// pixel format. It fast-paths the common case (32bpp, full 8-bit channels) +// with a tight loop that skips the per-channel *max/255 arithmetic and emits +// a single uint32 per pixel; the general path handles arbitrary formats. +func writePixels(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect, bytesPerPixel int) { + if bytesPerPixel == 4 && pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 { + writePixelsFast32(dst, img, pf, r) + return + } + writePixelsGeneric(dst, img, pf, r, bytesPerPixel) +} + +func writePixelsFast32(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect) { + stride := img.Stride + rShift, gShift, bShift := pf.rShift, pf.gShift, pf.bShift + bigEndian := pf.bigEndian != 0 + off := 0 + for row := r.y; row < r.y+r.h; row++ { + p := row*stride + r.x*4 + for col := 0; col < r.w; col++ { + pixel := (uint32(img.Pix[p]) << rShift) | + (uint32(img.Pix[p+1]) << gShift) | + (uint32(img.Pix[p+2]) << bShift) + if bigEndian { + binary.BigEndian.PutUint32(dst[off:off+4], pixel) + } else { + binary.LittleEndian.PutUint32(dst[off:off+4], pixel) + } + p += 4 + off += 4 + } + } +} + +func writePixelsGeneric(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect, bytesPerPixel int) { + stride := img.Stride + off := 0 + for row := r.y; row < r.y+r.h; row++ { + for col := r.x; col < r.x+r.w; col++ { + p := row*stride + col*4 + rv := uint32(img.Pix[p]) * uint32(pf.rMax) / 255 + gv := uint32(img.Pix[p+1]) * uint32(pf.gMax) / 255 + bv := uint32(img.Pix[p+2]) * uint32(pf.bMax) / 255 + pixel := (rv << pf.rShift) | (gv << pf.gShift) | (bv << pf.bShift) + emitPixelBytes(dst[off:off+bytesPerPixel], pixel, bytesPerPixel, pf.bigEndian != 0) + off += bytesPerPixel + } + } +} + +func emitPixelBytes(dst []byte, pixel uint32, bytesPerPixel int, bigEndian bool) { + if bigEndian { + for i := range bytesPerPixel { + dst[i] = byte(pixel >> uint((bytesPerPixel-1-i)*8)) + } + return + } + for i := range bytesPerPixel { + dst[i] = byte(pixel >> uint(i*8)) + } +} + +// vncAuthEncrypt encrypts a 16-byte challenge using the VNC DES scheme. +func vncAuthEncrypt(challenge []byte, password string) ([]byte, error) { + key := make([]byte, 8) + pw := []byte(password) + n := len(pw) + if n > 8 { + n = 8 + } + for i := 0; i < n; i++ { + key[i] = reverseBits(pw[i]) + } + block, err := des.NewCipher(key) //nolint:gosec // RFB protocol-defined DES challenge/response; not a confidentiality cipher + if err != nil { + return nil, fmt.Errorf("des.NewCipher: %w", err) + } + if len(challenge) < 16 { //nolint:gosec // explicit length check disarms G602 + return nil, fmt.Errorf("vnc auth challenge too short: %d", len(challenge)) + } + out := make([]byte, 16) + block.Encrypt(out[:8], challenge[:8]) + block.Encrypt(out[8:], challenge[8:]) + return out, nil +} + +func reverseBits(b byte) byte { + var r byte + for range 8 { + r = (r << 1) | (b & 1) + b >>= 1 + } + return r +} + +// encodeZlibRect encodes a framebuffer region using Zlib compression. +// The zlib stream is continuous for the entire VNC session: noVNC creates +// one inflate context at startup and reuses it for all zlib-encoded rects. +// We must NOT reset the zlib writer between calls. +func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zlibState) []byte { + bytesPerPixel := max(int(pf.bpp)/8, 1) + zw, zbuf := z.w, z.buf + + // Clear the output buffer but keep the deflate dictionary intact. + zbuf.Reset() + + // Encode the full rect pixel stream into the session-lived scratch buffer + // and feed zlib one row at a time. Row-granular writes amortise the per- + // Write overhead that used to dominate this function when it wrote one + // byte slice per pixel. + rowBytes := w * bytesPerPixel + total := rowBytes * h + if cap(z.scratch) < total { + z.scratch = make([]byte, total) + } + scratch := z.scratch[:total] + writePixels(scratch, img, pf, rect{x, y, w, h}, bytesPerPixel) + for row := 0; row < h; row++ { + if _, err := zw.Write(scratch[row*rowBytes : (row+1)*rowBytes]); err != nil { + log.Debugf("zlib write row %d: %v", row, err) + return nil + } + } + if err := zw.Flush(); err != nil { + log.Debugf("zlib flush: %v", err) + return nil + } + + compressed := zbuf.Bytes() + + // Build the FramebufferUpdate message. + buf := make([]byte, 4+12+4+len(compressed)) + buf[0] = serverFramebufferUpdate + buf[1] = 0 + binary.BigEndian.PutUint16(buf[2:4], 1) // 1 rectangle + + binary.BigEndian.PutUint16(buf[4:6], uint16(x)) + binary.BigEndian.PutUint16(buf[6:8], uint16(y)) + binary.BigEndian.PutUint16(buf[8:10], uint16(w)) + binary.BigEndian.PutUint16(buf[10:12], uint16(h)) + binary.BigEndian.PutUint32(buf[12:16], uint32(encZlib)) + binary.BigEndian.PutUint32(buf[16:20], uint32(len(compressed))) + copy(buf[20:], compressed) + + return buf +} + +// diffRects compares two RGBA images and returns a list of dirty rectangles. +// Divides the screen into tiles and checks each for changes. +func diffRects(prev, cur *image.RGBA, w, h, tileSize int) [][4]int { + if prev == nil { + return [][4]int{{0, 0, w, h}} + } + + var rects [][4]int + for ty := 0; ty < h; ty += tileSize { + th := min(tileSize, h-ty) + for tx := 0; tx < w; tx += tileSize { + tw := min(tileSize, w-tx) + if tileChanged(prev, cur, tx, ty, tw, th) { + rects = append(rects, [4]int{tx, ty, tw, th}) + } + } + } + return coalesceRects(rects) +} + +// coalesceRects merges adjacent dirty tiles into larger rectangles to cut +// per-rect framing overhead. Input must be tile-ordered (top-to-bottom rows, +// left-to-right within each row), as produced by diffRects. Two passes: +// 1. Horizontal: within a row, merge tiles whose x-extents touch. +// 2. Vertical: merge a row's run with the run directly above it when they +// share the same [x, x+w] extent and are vertically adjacent. +// +// Larger merged rects still encode correctly: Hextile-solid and Zlib paths +// both work on arbitrary sizes, and uniform-tile detection still fires when +// the merged region happens to be a single colour. +func coalesceRects(in [][4]int) [][4]int { + if len(in) < 2 { + return in + } + c := newRectCoalescer(len(in)) + c.curY = in[0][1] + for _, r := range in { + c.consume(r) + } + c.flushCurrentRow() + return c.out +} + +// rectCoalescer is the working state for coalesceRects, lifted out so the +// algorithm can be split across small methods without long parameter lists +// and to keep each method's cognitive complexity below Sonar's threshold. +type rectCoalescer struct { + out [][4]int + prevRowStart, prevRowEnd int + curRowStart int + curY int +} + +func newRectCoalescer(cap int) *rectCoalescer { + return &rectCoalescer{out: make([][4]int, 0, cap)} +} + +// consume processes one rect from the (row-ordered) input. +func (c *rectCoalescer) consume(r [4]int) { + if r[1] != c.curY { + c.flushCurrentRow() + c.prevRowEnd = len(c.out) + c.curRowStart = len(c.out) + c.curY = r[1] + } + if c.tryHorizontalMerge(r) { + return + } + c.out = append(c.out, r) +} + +// tryHorizontalMerge extends the last run in the current row when r is +// vertically aligned and horizontally adjacent to it. +func (c *rectCoalescer) tryHorizontalMerge(r [4]int) bool { + if len(c.out) <= c.curRowStart { + return false + } + last := &c.out[len(c.out)-1] + if last[1] == r[1] && last[3] == r[3] && last[0]+last[2] == r[0] { + last[2] += r[2] + return true + } + return false +} + +// flushCurrentRow merges each run in the current row with any run from the +// previous row that has identical x extent and is vertically adjacent. +func (c *rectCoalescer) flushCurrentRow() { + i := c.curRowStart + for i < len(c.out) { + if c.mergeWithPrevRow(i) { + continue + } + i++ + } +} + +// mergeWithPrevRow tries to extend a previous-row run downward to absorb +// out[i]. Returns true and removes out[i] from the slice on success. +func (c *rectCoalescer) mergeWithPrevRow(i int) bool { + for j := c.prevRowStart; j < c.prevRowEnd; j++ { + if c.out[j][0] == c.out[i][0] && + c.out[j][2] == c.out[i][2] && + c.out[j][1]+c.out[j][3] == c.out[i][1] { + c.out[j][3] += c.out[i][3] + copy(c.out[i:], c.out[i+1:]) + c.out = c.out[:len(c.out)-1] + return true + } + } + return false +} + +func tileChanged(prev, cur *image.RGBA, x, y, w, h int) bool { + stride := prev.Stride + for row := y; row < y+h; row++ { + off := row*stride + x*4 + end := off + w*4 + prevRow := prev.Pix[off:end] + curRow := cur.Pix[off:end] + if !bytes.Equal(prevRow, curRow) { + return true + } + } + return false +} + +// tileIsUniform reports whether every pixel in the given rectangle of img is +// the same RGBA value, and returns that pixel packed as 0xRRGGBBAA when so. +// Uses uint32 comparisons across rows; returns early on the first mismatch. +func tileIsUniform(img *image.RGBA, x, y, w, h int) (uint32, bool) { + if w <= 0 || h <= 0 { + return 0, false + } + stride := img.Stride + base := y*stride + x*4 + first := *(*uint32)(unsafe.Pointer(&img.Pix[base])) + rowBytes := w * 4 + for row := 0; row < h; row++ { + p := base + row*stride + for col := 0; col < rowBytes; col += 4 { + if *(*uint32)(unsafe.Pointer(&img.Pix[p+col])) != first { + return 0, false + } + } + } + return first, true +} + +// encodePixel packs an RGBA byte triple into the client's requested pixel +// format, honouring bpp, channel maxes, shifts and endianness. Returns the +// number of bytes written to dst (1..4). +func encodePixel(dst []byte, pf clientPixelFormat, r, g, b byte) int { + bytesPerPixel := max(int(pf.bpp)/8, 1) + var val uint32 + if pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 { + val = (uint32(r) << pf.rShift) | (uint32(g) << pf.gShift) | (uint32(b) << pf.bShift) + } else { + rv := uint32(r) * uint32(pf.rMax) / 255 + gv := uint32(g) * uint32(pf.gMax) / 255 + bv := uint32(b) * uint32(pf.bMax) / 255 + val = (rv << pf.rShift) | (gv << pf.gShift) | (bv << pf.bShift) + } + if pf.bigEndian != 0 { + for i := range bytesPerPixel { + dst[i] = byte(val >> uint((bytesPerPixel-1-i)*8)) + } + } else { + for i := range bytesPerPixel { + dst[i] = byte(val >> uint(i*8)) + } + } + return bytesPerPixel +} + +// encodeHextileSolidRect emits a Hextile-encoded rectangle whose every pixel +// is the same color. All sub-tiles after the first inherit the background +// via a zero subencoding byte, collapsing a uniform 64×64 tile from ~16 KB +// raw (or ~1-2 KB zlib) down to ~20 bytes on the wire. +// +// The returned buffer starts with the 12-byte rect header + the hextile +// body. Callers assembling a multi-rect FramebufferUpdate append this after +// their own message header. +func encodeHextileSolidRect(r, g, b byte, pf clientPixelFormat, rc rect) []byte { + bytesPerPixel := max(int(pf.bpp)/8, 1) + + // Count sub-tiles. Right/bottom sub-tiles may be smaller than 16. + cols := (rc.w + hextileSubSize - 1) / hextileSubSize + rows := (rc.h + hextileSubSize - 1) / hextileSubSize + subs := cols * rows + + // Body: first sub-tile carries (subenc 0x02 + bg pixel); the rest are + // subenc 0x00 (inherit the previously-emitted background). + bodySize := 1 + bytesPerPixel + (subs - 1) + buf := make([]byte, 12+bodySize) + + binary.BigEndian.PutUint16(buf[0:2], uint16(rc.x)) + binary.BigEndian.PutUint16(buf[2:4], uint16(rc.y)) + binary.BigEndian.PutUint16(buf[4:6], uint16(rc.w)) + binary.BigEndian.PutUint16(buf[6:8], uint16(rc.h)) + binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile)) + + buf[12] = hextileBackgroundSpecified + encodePixel(buf[13:13+bytesPerPixel], pf, r, g, b) + // Remaining sub-tiles are already zero-valued from make(): "same as + // previous background", no pixel bytes. + _ = subs + return buf +} + +// encodeHextileRect emits a full Hextile-encoded rectangle. Each 16×16 +// sub-tile is classified as 1-color (background only), 2-color (background +// + foreground subrects), or raw. The 1-color and 2-color paths are +// significantly cheaper than zlib on UI content (text, icons, flat +// backgrounds) and avoid the persistent zlib stream's inter-rect +// serialization point, so they parallelize trivially. +// +// The returned buffer starts with the 12-byte rect header + hextile body. +func encodeHextileRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte { + bytesPerPixel := max(int(pf.bpp)/8, 1) + + // Pre-size: worst case is every sub-tile raw → 1 header byte + raw + // pixels per sub-tile. + maxBody := 0 + for sy := 0; sy < h; sy += hextileSubSize { + sh := min(hextileSubSize, h-sy) + for sx := 0; sx < w; sx += hextileSubSize { + sw := min(hextileSubSize, w-sx) + maxBody += 1 + sw*sh*bytesPerPixel + } + } + buf := make([]byte, 12, 12+maxBody) + + binary.BigEndian.PutUint16(buf[0:2], uint16(x)) + binary.BigEndian.PutUint16(buf[2:4], uint16(y)) + binary.BigEndian.PutUint16(buf[4:6], uint16(w)) + binary.BigEndian.PutUint16(buf[6:8], uint16(h)) + binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile)) + + var state hextileBgState + + for sy := 0; sy < h; sy += hextileSubSize { + sh := min(hextileSubSize, h-sy) + for sx := 0; sx < w; sx += hextileSubSize { + sw := min(hextileSubSize, w-sx) + buf = appendHextileSubtile(buf, img, pf, rect{x + sx, y + sy, sw, sh}, &state, bytesPerPixel) + } + } + return buf +} + +// hextileBgState carries the running background across sub-tile encodes so +// we can omit the BackgroundSpecified flag when it hasn't changed. +type hextileBgState struct { + prev uint32 + valid bool +} + +// appendHextileSubtile encodes a single 16×16 (or smaller edge) sub-tile +// onto buf. +func appendHextileSubtile(buf []byte, img *image.RGBA, pf clientPixelFormat, rc rect, state *hextileBgState, bytesPerPixel int) []byte { + x, y, w, h := rc.x, rc.y, rc.w, rc.h + c0, c1, only2, c0Count, c1Count := classifySubtile(img, x, y, w, h) + + if !only2 { + // >2 distinct colours: raw fallback. + buf = append(buf, hextileRaw) + buf = appendRawPixels(buf, img, pf, rc, bytesPerPixel) + state.valid = false + return buf + } + + if c1Count == 0 { + // Single colour. Background only. + if state.valid && state.prev == c0 { + return append(buf, 0) + } + buf = append(buf, hextileBackgroundSpecified) + buf = appendPackedPixelFromRGBA(buf, pf, c0, bytesPerPixel) + state.prev = c0 + state.valid = true + return buf + } + + // Two colours. Background = majority; foreground = minority, + // emitted as 1-row subrects of fg runs. + bg, fg := c0, c1 + if c1Count > c0Count { + bg, fg = c1, c0 + } + subrects := collectFgSubrects(img, x, y, w, h, bg) + // Cap at 255 (the count is a uint8). On overflow fall through to + // raw: that's the simplest correct fallback. + if len(subrects) <= 255 { + flags := byte(hextileForegroundSpecified | hextileAnySubrects) + emitBg := !state.valid || state.prev != bg + if emitBg { + flags |= hextileBackgroundSpecified + } + buf = append(buf, flags) + if emitBg { + buf = appendPackedPixelFromRGBA(buf, pf, bg, bytesPerPixel) + state.prev = bg + state.valid = true + } + buf = appendPackedPixelFromRGBA(buf, pf, fg, bytesPerPixel) + buf = append(buf, byte(len(subrects))) + for _, sr := range subrects { + buf = append(buf, byte((sr[0]<<4)|sr[1]), byte(((sr[2]-1)<<4)|(sr[3]-1))) + } + return buf + } + + // Raw fallback. + buf = append(buf, hextileRaw) + buf = appendRawPixels(buf, img, pf, rc, bytesPerPixel) + // Raw sub-tiles invalidate the persistent background. + state.valid = false + return buf +} + +// classifySubtile scans the sub-tile and reports up to two distinct pixel +// values plus their counts. only2 is false the moment a third distinct +// colour is seen, in which case the caller falls back to raw. +func classifySubtile(img *image.RGBA, x, y, w, h int) (c0, c1 uint32, only2 bool, c0Count, c1Count int) { + stride := img.Stride + base := y*stride + x*4 + c0 = *(*uint32)(unsafe.Pointer(&img.Pix[base])) + only2 = true + for row := 0; row < h; row++ { + p := base + row*stride + for col := 0; col < w; col++ { + px := *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) + switch { + case px == c0: + c0Count++ + case c1Count == 0: + c1 = px + c1Count = 1 + case px == c1: + c1Count++ + default: + return c0, c1, false, 0, 0 + } + } + } + return c0, c1, only2, c0Count, c1Count +} + +// collectFgSubrects walks the sub-tile row by row, emitting one subrect per +// horizontal run of pixels not equal to bg. Each subrect is [subX, subY, +// width, height] with width/height in 1..16. +func collectFgSubrects(img *image.RGBA, x, y, w, h int, bg uint32) [][4]int { + stride := img.Stride + var out [][4]int + for row := 0; row < h; row++ { + p := y*stride + x*4 + row*stride + col := 0 + for col < w { + if *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) == bg { + col++ + continue + } + start := col + for col < w && *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) != bg { + col++ + } + out = append(out, [4]int{start, row, col - start, 1}) + } + } + return out +} + +func appendPackedPixelFromRGBA(buf []byte, pf clientPixelFormat, px uint32, bytesPerPixel int) []byte { + r := byte(px) + g := byte(px >> 8) + b := byte(px >> 16) + var tmp [4]byte + encodePixel(tmp[:], pf, r, g, b) + return append(buf, tmp[:bytesPerPixel]...) +} + +func appendRawPixels(buf []byte, img *image.RGBA, pf clientPixelFormat, rc rect, bytesPerPixel int) []byte { + start := len(buf) + buf = append(buf, make([]byte, rc.w*rc.h*bytesPerPixel)...) + writePixels(buf[start:], img, pf, rc, bytesPerPixel) + return buf +} + +// tightState holds the per-session JPEG scratch buffer and reused encoders +// so per-rect encoding stays alloc-free in the steady state. +type tightState struct { + jpegBuf *bytes.Buffer + zlib *zlibState + scratch []byte // RGB-packed pixel scratch for JPEG and Basic paths. + // colorSeen is reused by sampledColorCount per rect; cleared via the Go + // runtime's map-clear fast path to avoid a fresh allocation each call. + colorSeen map[uint32]struct{} +} + +func newTightState() *tightState { + return &tightState{ + jpegBuf: &bytes.Buffer{}, + zlib: newZlibState(), + colorSeen: make(map[uint32]struct{}, 64), + } +} + +// encodeTightRect emits a single Tight-encoded rect. Picks Fill for uniform +// content, JPEG for photo-like rects above a size and color-count threshold, +// and Basic+zlib otherwise. Returns the rect header + Tight body (no +// FramebufferUpdate header). +func encodeTightRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, t *tightState) []byte { + if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform { + return encodeTightFill(x, y, w, h, byte(pixel), byte(pixel>>8), byte(pixel>>16)) + } + if w*h >= tightJPEGMinArea && sampledColorCountInto(t.colorSeen, img, x, y, w, h, tightJPEGMinColors) >= tightJPEGMinColors { + if buf, ok := encodeTightJPEG(img, x, y, w, h, t); ok { + return buf + } + } + return encodeTightBasic(img, x, y, w, h, t) +} + +func writeTightRectHeader(buf []byte, x, y, w, h int) { + binary.BigEndian.PutUint16(buf[0:2], uint16(x)) + binary.BigEndian.PutUint16(buf[2:4], uint16(y)) + binary.BigEndian.PutUint16(buf[4:6], uint16(w)) + binary.BigEndian.PutUint16(buf[6:8], uint16(h)) + binary.BigEndian.PutUint32(buf[8:12], uint32(encTight)) +} + +// appendTightLength encodes a Tight compact length prefix (1, 2, or 3 bytes +// LE-ish, top bit of each byte signals continuation). +func appendTightLength(buf []byte, n int) []byte { + b0 := byte(n & 0x7f) + if n <= 0x7f { + return append(buf, b0) + } + b0 |= 0x80 + b1 := byte((n >> 7) & 0x7f) + if n <= 0x3fff { + return append(buf, b0, b1) + } + b1 |= 0x80 + b2 := byte((n >> 14) & 0xff) + return append(buf, b0, b1, b2) +} + +// encodeTightFill emits a uniform rect: 12-byte rect header + 1-byte +// subenc (0x80) + 3-byte RGB pixel. Tight Fill always uses 24-bit RGB +// regardless of the negotiated pixel format. +func encodeTightFill(x, y, w, h int, r, g, b byte) []byte { + buf := make([]byte, 12+1+3) + writeTightRectHeader(buf, x, y, w, h) + buf[12] = tightFillSubenc + buf[13] = r + buf[14] = g + buf[15] = b + return buf +} + +// encodeTightJPEG compresses the rect as a baseline JPEG. Returns ok=false +// if the encoder errors so the caller can fall back to Basic. +func encodeTightJPEG(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bool) { + t.jpegBuf.Reset() + sub := img.SubImage(image.Rect(img.Rect.Min.X+x, img.Rect.Min.Y+y, img.Rect.Min.X+x+w, img.Rect.Min.Y+y+h)) + if err := jpeg.Encode(t.jpegBuf, sub, &jpeg.Options{Quality: tightQualityFor(w * h)}); err != nil { + return nil, false + } + jpegBytes := t.jpegBuf.Bytes() + buf := make([]byte, 0, 12+1+3+len(jpegBytes)) + buf = buf[:12] + writeTightRectHeader(buf, x, y, w, h) + buf = append(buf, tightJPEGSubenc) + buf = appendTightLength(buf, len(jpegBytes)) + buf = append(buf, jpegBytes...) + return buf, true +} + +// encodeTightBasic emits Basic+zlib with the no-op (CopyFilter) filter. +// Pixels are sent as 24-bit RGB ("TPIXEL" format) which most clients +// negotiate when the server advertises 32bpp true colour. Streams under +// 12 bytes ship uncompressed per RFB Tight spec. +func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) []byte { + pixelStream := w * h * 3 + if cap(t.scratch) < pixelStream { + t.scratch = make([]byte, pixelStream) + } + scratch := t.scratch[:pixelStream] + stride := img.Stride + off := 0 + for row := y; row < y+h; row++ { + p := row*stride + x*4 + for col := 0; col < w; col++ { + scratch[off+0] = img.Pix[p] + scratch[off+1] = img.Pix[p+1] + scratch[off+2] = img.Pix[p+2] + p += 4 + off += 3 + } + } + + // Sub-encoding byte: stream 0, no resets, basic encoding (top nibble + // = 0x40 = explicit filter follows). + subenc := byte(tightBasicFilter) + filter := byte(tightFilterCopy) + + if pixelStream < 12 { + buf := make([]byte, 0, 12+2+pixelStream) + buf = buf[:12] + writeTightRectHeader(buf, x, y, w, h) + buf = append(buf, subenc, filter) + buf = append(buf, scratch...) + return buf + } + + z := t.zlib + z.buf.Reset() + if _, err := z.w.Write(scratch); err != nil { + log.Debugf("tight zlib write: %v", err) + return nil + } + if err := z.w.Flush(); err != nil { + log.Debugf("tight zlib flush: %v", err) + return nil + } + compressed := z.buf.Bytes() + + buf := make([]byte, 0, 12+2+5+len(compressed)) + buf = buf[:12] + writeTightRectHeader(buf, x, y, w, h) + buf = append(buf, subenc, filter) + buf = appendTightLength(buf, len(compressed)) + buf = append(buf, compressed...) + return buf +} + +func tightQualityFor(pixels int) int { + switch { + case pixels >= tightJPEGLargePixels: + return tightJPEGQualityLarge + case pixels >= tightJPEGMediumPixels: + return tightJPEGQualityMedium + default: + return tightJPEGQuality + } +} + +// sampledColorCountInto estimates distinct-colour count by checking up to +// maxColors samples. The caller-provided `seen` map is cleared and reused so +// per-rect Tight encoding stays alloc-free. Cheap O(maxColors) per call. +func sampledColorCountInto(seen map[uint32]struct{}, img *image.RGBA, x, y, w, h, maxColors int) int { + clear(seen) + stride := img.Stride + step := max((w*h)/(maxColors*4), 1) + var idx int + for row := 0; row < h; row++ { + p := (y+row)*stride + x*4 + for col := 0; col < w; col++ { + if idx%step == 0 { + px := *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) + seen[px&0x00ffffff] = struct{}{} + if len(seen) > maxColors { + return len(seen) + } + } + idx++ + } + } + return len(seen) +} + +// zlibState holds the persistent zlib writer, output buffer, and a scratch +// slice reused by encodeZlibRect to stage the packed pixel stream before +// handing it to the deflate writer. The scratch grows to the largest rect +// we've seen and is kept for the session lifetime. +type zlibState struct { + buf *bytes.Buffer + w *zlib.Writer + scratch []byte +} + +func newZlibState() *zlibState { + buf := &bytes.Buffer{} + w, _ := zlib.NewWriterLevel(buf, zlib.BestSpeed) + return &zlibState{buf: buf, w: w} +} + +func (z *zlibState) Close() error { + return z.w.Close() +} diff --git a/client/vnc/server/rfb_bench_test.go b/client/vnc/server/rfb_bench_test.go new file mode 100644 index 00000000000..c26672da92c --- /dev/null +++ b/client/vnc/server/rfb_bench_test.go @@ -0,0 +1,405 @@ +package server + +import ( + "image" + "math/rand" + "testing" +) + +// Representative frame sizes. +var benchRects = []struct { + name string + w, h int +}{ + {"1080p_full", 1920, 1080}, + {"720p_full", 1280, 720}, + {"256x256_tile", 256, 256}, + {"64x64_tile", 64, 64}, +} + +func makeBenchImage(w, h int, seed int64) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + r := rand.New(rand.NewSource(seed)) + _, _ = r.Read(img.Pix) + // Force alpha byte so the fast path and slow path produce identical output. + for i := 3; i < len(img.Pix); i += 4 { + img.Pix[i] = 0xff + } + return img +} + +func makeBenchImagePartial(w, h, changedRows int) (*image.RGBA, *image.RGBA) { + prev := makeBenchImage(w, h, 1) + cur := image.NewRGBA(prev.Rect) + copy(cur.Pix, prev.Pix) + if changedRows > h { + changedRows = h + } + // Dirty the first `changedRows` rows. + r := rand.New(rand.NewSource(2)) + _, _ = r.Read(cur.Pix[:changedRows*cur.Stride]) + for i := 3; i < len(cur.Pix); i += 4 { + cur.Pix[i] = 0xff + } + return prev, cur +} + +func BenchmarkEncodeRawRect(b *testing.B) { + pf := defaultClientPixelFormat() + for _, r := range benchRects { + img := makeBenchImage(r.w, r.h, 1) + b.Run(r.name, func(b *testing.B) { + b.SetBytes(int64(r.w * r.h * 4)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = encodeRawRect(img, pf, 0, 0, r.w, r.h) + } + }) + } +} + +func BenchmarkEncodeZlibRect(b *testing.B) { + pf := defaultClientPixelFormat() + for _, r := range benchRects { + img := makeBenchImage(r.w, r.h, 1) + z := newZlibState() + b.Run(r.name, func(b *testing.B) { + b.SetBytes(int64(r.w * r.h * 4)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = encodeZlibRect(img, pf, 0, 0, r.w, r.h, z) + } + }) + } +} + +// BenchmarkWritePixels isolates the per-pixel pack loop from the allocation +// and FramebufferUpdate-header overhead. +func BenchmarkWritePixels(b *testing.B) { + pf := defaultClientPixelFormat() + for _, r := range benchRects { + img := makeBenchImage(r.w, r.h, 1) + dst := make([]byte, r.w*r.h*4) + b.Run(r.name, func(b *testing.B) { + b.SetBytes(int64(r.w * r.h * 4)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + writePixels(dst, img, pf, rect{0, 0, r.w, r.h}, 4) + } + }) + } +} + +// BenchmarkWritePixelsScaled forces the general (non-fast) path by using a +// pixel format with non-255 channel maxes. +func BenchmarkWritePixelsScaled(b *testing.B) { + pf := defaultClientPixelFormat() + pf.rMax, pf.gMax, pf.bMax = 31, 63, 31 // 16bpp-ish; exercises the divide path + pf.bpp = 16 + for _, r := range benchRects { + img := makeBenchImage(r.w, r.h, 1) + dst := make([]byte, r.w*r.h*2) + b.Run(r.name, func(b *testing.B) { + b.SetBytes(int64(r.w * r.h * 4)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + writePixels(dst, img, pf, rect{0, 0, r.w, r.h}, 2) + } + }) + } +} + +func BenchmarkSwizzleBGRAtoRGBA(b *testing.B) { + for _, r := range benchRects { + size := r.w * r.h * 4 + src := make([]byte, size) + dst := make([]byte, size) + rng := rand.New(rand.NewSource(1)) + _, _ = rng.Read(src) + b.Run(r.name, func(b *testing.B) { + b.SetBytes(int64(size)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + swizzleBGRAtoRGBA(dst, src) + } + }) + } +} + +// BenchmarkSwizzleBGRAtoRGBANaive is the naive byte-by-byte implementation +// that the Linux SHM capturer used before the uint32 rewrite, kept here so +// we can compare the cost directly. +func BenchmarkSwizzleBGRAtoRGBANaive(b *testing.B) { + for _, r := range benchRects { + size := r.w * r.h * 4 + src := make([]byte, size) + dst := make([]byte, size) + rng := rand.New(rand.NewSource(1)) + _, _ = rng.Read(src) + b.Run(r.name, func(b *testing.B) { + b.SetBytes(int64(size)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + for j := 0; j < size; j += 4 { + dst[j+0] = src[j+2] + dst[j+1] = src[j+1] + dst[j+2] = src[j+0] + dst[j+3] = 0xff + } + } + }) + } +} + +// BenchmarkEncodeUniformTile_Zlib measures the cost of sending a uniform +// 64×64 dirty tile via zlib (the old path before the Hextile fast path). +func BenchmarkEncodeUniformTile_Zlib(b *testing.B) { + pf := defaultClientPixelFormat() + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i+0] = 0x33 + img.Pix[i+1] = 0x66 + img.Pix[i+2] = 0x99 + img.Pix[i+3] = 0xff + } + z := newZlibState() + b.ReportAllocs() + var bytesOut int + for i := 0; i < b.N; i++ { + out := encodeZlibRect(img, pf, 0, 0, 64, 64, z) + bytesOut = len(out) + } + b.ReportMetric(float64(bytesOut), "wire_bytes") +} + +// BenchmarkEncodeUniformTile_Hextile measures the new fast path: uniform +// 64×64 tile emitted as Hextile SolidFill. +func BenchmarkEncodeUniformTile_Hextile(b *testing.B) { + pf := defaultClientPixelFormat() + b.ReportAllocs() + var bytesOut int + for i := 0; i < b.N; i++ { + out := encodeHextileSolidRect(0x33, 0x66, 0x99, pf, rect{0, 0, 64, 64}) + bytesOut = len(out) + } + b.ReportMetric(float64(bytesOut), "wire_bytes") +} + +func BenchmarkTileIsUniform(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i+3] = 0xff + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _ = tileIsUniform(img, 0, 0, 64, 64) + } +} + +// BenchmarkEncodeManyTilesVsFullFrame exercises the bandwidth + CPU +// trade-off that motivates the full-frame promotion path: encoding a burst +// of N dirty 64×64 tiles as separate zlib rects vs emitting one big zlib +// rect for the whole frame. +func BenchmarkEncodeManyTilesVsFullFrame(b *testing.B) { + pf := defaultClientPixelFormat() + const w, h = 1920, 1080 + img := makeBenchImage(w, h, 1) + + // Build the list of every tile in the frame (worst case: entire screen dirty). + var tiles [][4]int + for ty := 0; ty < h; ty += tileSize { + th := tileSize + if ty+th > h { + th = h - ty + } + for tx := 0; tx < w; tx += tileSize { + tw := tileSize + if tx+tw > w { + tw = w - tx + } + tiles = append(tiles, [4]int{tx, ty, tw, th}) + } + } + nTiles := len(tiles) + + b.Run("per_tile_zlib", func(b *testing.B) { + z := newZlibState() + b.SetBytes(int64(w * h * 4)) + b.ReportAllocs() + var totalOut int + for i := 0; i < b.N; i++ { + totalOut = 0 + for _, r := range tiles { + out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z) + totalOut += len(out) + } + } + b.ReportMetric(float64(totalOut), "wire_bytes") + b.ReportMetric(float64(nTiles), "tiles") + }) + + b.Run("full_frame_zlib", func(b *testing.B) { + z := newZlibState() + b.SetBytes(int64(w * h * 4)) + b.ReportAllocs() + var totalOut int + for i := 0; i < b.N; i++ { + out := encodeZlibRect(img, pf, 0, 0, w, h, z) + totalOut = len(out) + } + b.ReportMetric(float64(totalOut), "wire_bytes") + }) +} + +// BenchmarkShouldPromoteToFullFrame verifies the threshold check itself is +// cheap. It runs on every frame, so regressions here hit all workloads. +func BenchmarkShouldPromoteToFullFrame(b *testing.B) { + const w, h = 1920, 1080 + s := &session{serverW: w, serverH: h} + // Build a worst-case rect list (every tile dirty, 510 entries). + var rects [][4]int + for ty := 0; ty < h; ty += tileSize { + th := tileSize + if ty+th > h { + th = h - ty + } + for tx := 0; tx < w; tx += tileSize { + tw := tileSize + if tx+tw > w { + tw = w - tx + } + rects = append(rects, [4]int{tx, ty, tw, th}) + } + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = s.shouldPromoteToFullFrame(rects) + } +} + +// BenchmarkEncodeCoalescedVsPerTile compares per-tile encoding vs the +// coalesced rect list emitted by diffRects, on a horizontal-band dirty +// pattern (e.g. a scrolling status bar) where coalescing pays off. +func BenchmarkEncodeCoalescedVsPerTile(b *testing.B) { + pf := defaultClientPixelFormat() + const w, h = 1920, 1080 + img := makeBenchImage(w, h, 1) + + // Dirty band: rows 200..264 (one tile-row), full width. + var perTile [][4]int + for tx := 0; tx < w; tx += tileSize { + tw := tileSize + if tx+tw > w { + tw = w - tx + } + perTile = append(perTile, [4]int{tx, 200, tw, tileSize}) + } + coalesced := coalesceRects(append([][4]int(nil), perTile...)) + + b.Run("per_tile", func(b *testing.B) { + z := newZlibState() + b.ReportAllocs() + var bytesOut int + for i := 0; i < b.N; i++ { + bytesOut = 0 + for _, r := range perTile { + out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z) + bytesOut += len(out) + } + } + b.ReportMetric(float64(bytesOut), "wire_bytes") + b.ReportMetric(float64(len(perTile)), "rects") + }) + + b.Run("coalesced", func(b *testing.B) { + z := newZlibState() + b.ReportAllocs() + var bytesOut int + for i := 0; i < b.N; i++ { + bytesOut = 0 + for _, r := range coalesced { + out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z) + bytesOut += len(out) + } + } + b.ReportMetric(float64(bytesOut), "wire_bytes") + b.ReportMetric(float64(len(coalesced)), "rects") + }) +} + +func BenchmarkCoalesceRects(b *testing.B) { + const w, h = 1920, 1080 + // Worst case: every tile dirty. + var allTiles [][4]int + for ty := 0; ty < h; ty += tileSize { + th := tileSize + if ty+th > h { + th = h - ty + } + for tx := 0; tx < w; tx += tileSize { + tw := tileSize + if tx+tw > w { + tw = w - tx + } + allTiles = append(allTiles, [4]int{tx, ty, tw, th}) + } + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + in := make([][4]int, len(allTiles)) + copy(in, allTiles) + _ = coalesceRects(in) + } +} + +// BenchmarkEncodeTightVsZlib_Photo compares Tight (which routes random/ +// photographic content to JPEG) against the persistent Zlib stream. JPEG +// at quality 70 should be 5-15× smaller on this kind of content. +func BenchmarkEncodeTightVsZlib_Photo(b *testing.B) { + pf := defaultClientPixelFormat() + for _, r := range []struct { + name string + w, h int + }{ + {"256x256", 256, 256}, + {"512x512", 512, 512}, + {"1080p", 1920, 1080}, + } { + img := makeBenchImage(r.w, r.h, 1) + b.Run(r.name+"/zlib", func(b *testing.B) { + z := newZlibState() + b.SetBytes(int64(r.w * r.h * 4)) + b.ReportAllocs() + var bytesOut int + for i := 0; i < b.N; i++ { + out := encodeZlibRect(img, pf, 0, 0, r.w, r.h, z) + bytesOut = len(out) + } + b.ReportMetric(float64(bytesOut), "wire_bytes") + }) + b.Run(r.name+"/tight", func(b *testing.B) { + t := newTightState() + b.SetBytes(int64(r.w * r.h * 4)) + b.ReportAllocs() + var bytesOut int + for i := 0; i < b.N; i++ { + out := encodeTightRect(img, pf, 0, 0, r.w, r.h, t) + bytesOut = len(out) + } + b.ReportMetric(float64(bytesOut), "wire_bytes") + }) + } +} + +func BenchmarkDiffRects(b *testing.B) { + for _, r := range benchRects { + prev, cur := makeBenchImagePartial(r.w, r.h, 100) + b.Run(r.name, func(b *testing.B) { + b.SetBytes(int64(r.w * r.h * 4)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = diffRects(prev, cur, r.w, r.h, tileSize) + } + }) + } +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go new file mode 100644 index 00000000000..a7451eb87e5 --- /dev/null +++ b/client/vnc/server/server.go @@ -0,0 +1,754 @@ +package server + +import ( + "context" + "crypto/subtle" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "image" + "io" + "net" + "net/netip" + "strings" + "sync" + "time" + + gojwt "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/tun/netstack" + + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" +) + +// Connection modes sent by the client in the session header. +const ( + ModeAttach byte = 0 // Capture current display + ModeSession byte = 1 // Virtual session as specified user +) + +// RFB security-failure reason codes sent to the client. These prefixes are +// stable so dashboard/noVNC integrations can branch on them without parsing +// free text. Format: "CODE: human message". +const ( + RejectCodeJWTMissing = "AUTH_JWT_MISSING" + RejectCodeJWTExpired = "AUTH_JWT_EXPIRED" + RejectCodeJWTInvalid = "AUTH_JWT_INVALID" + RejectCodeAuthForbidden = "AUTH_FORBIDDEN" + RejectCodeAuthConfig = "AUTH_CONFIG" + RejectCodeSessionError = "SESSION_ERROR" + RejectCodeCapturerError = "CAPTURER_ERROR" + RejectCodeUnsupportedOS = "UNSUPPORTED" + RejectCodeBadRequest = "BAD_REQUEST" +) + +// EnvVNCDisableDownscale disables any platform-specific framebuffer +// downscaling (e.g. Retina 2:1). Set to 1/true to send the native resolution. +const EnvVNCDisableDownscale = "NB_VNC_DISABLE_DOWNSCALE" + +// freshWindow is how long an on-demand capturer may reuse its last result +// before triggering a new capture. Short enough to feel responsive, long +// enough to coalesce bursty multi-session requests. 16 ms ~= 60 fps. +const freshWindow = 16 * time.Millisecond + +// ScreenCapturer grabs desktop frames for the VNC server. +type ScreenCapturer interface { + // Width returns the current screen width in pixels. + Width() int + // Height returns the current screen height in pixels. + Height() int + // Capture returns the current desktop as an RGBA image. + Capture() (*image.RGBA, error) +} + +// captureIntoer is implemented by capturers that can write directly into a +// caller-provided buffer, skipping the per-frame snapshot copy that the +// session would otherwise need to make. Linux and macOS implement this. +type captureIntoer interface { + CaptureInto(dst *image.RGBA) error +} + +// errFrameUnchanged is returned by capturers that hash the raw source +// bytes (currently macOS) when the new frame is byte-identical to the +// last one, so the encoder can short-circuit to an empty update. +var errFrameUnchanged = errors.New("frame unchanged") + +// InputInjector delivers keyboard and mouse events to the OS. +type InputInjector interface { + // InjectKey simulates a key press or release. keysym is an X11 KeySym. + InjectKey(keysym uint32, down bool) + // InjectPointer simulates mouse movement and button state. + InjectPointer(buttonMask uint8, x, y, serverW, serverH int) + // SetClipboard sets the system clipboard to the given text. + SetClipboard(text string) + // GetClipboard returns the current system clipboard text. + GetClipboard() string + // TypeText synthesizes the given text as keystrokes on the active + // desktop. Used by the dashboard's Paste button to push host clipboard + // content into a secure desktop (Winlogon/UAC) where the clipboard is + // isolated. On platforms or sessions without keystroke synthesis it + // may be a no-op. + TypeText(text string) +} + +// JWTConfig holds JWT validation configuration for VNC auth. +type JWTConfig struct { + Issuer string + KeysLocation string + MaxTokenAge int64 + Audiences []string +} + +// connectionHeader is sent by the client before the RFB handshake to specify +// the VNC session mode and authenticate. +type connectionHeader struct { + mode byte + username string + jwt string + sessionID uint32 // Windows session ID (0 = console/auto) + // width and height request the virtual display geometry for session mode. + // Zero means use the default. + width uint16 + height uint16 +} + +// Server is the embedded VNC server that listens on the WireGuard interface. +// It supports two operating modes: +// - Direct mode: captures the screen and handles VNC sessions in-process. +// Used when running in a user session with desktop access. +// - Service mode: proxies VNC connections to an agent process spawned in +// the active console session. Used when running as a Windows service in +// Session 0. +// +// Within direct mode, each connection can request one of two session modes +// via the connection header: +// - Attach: capture the current physical display. +// - Session: start a virtual Xvfb display as the requested user. +type Server struct { + capturer ScreenCapturer + injector InputInjector + password string + serviceMode bool + disableAuth bool + localAddr netip.Addr // NetBird WireGuard IP this server is bound to + network netip.Prefix // NetBird overlay network + log *log.Entry + + mu sync.Mutex + listener net.Listener + ctx context.Context + cancel context.CancelFunc + vmgr virtualSessionManager + jwtConfig *JWTConfig + jwtValidator *nbjwt.Validator + jwtExtractor *nbjwt.ClaimsExtractor + authorizer *sshauth.Authorizer + netstackNet *netstack.Net + agentToken []byte // raw token bytes for agent-mode auth +} + +// vncSession provides capturer and injector for a virtual display session. +type vncSession interface { + Capturer() ScreenCapturer + Injector() InputInjector + Display() string + ClientConnect() + ClientDisconnect() +} + +// virtualSessionManager is implemented by sessionManager on Linux. +type virtualSessionManager interface { + // GetOrCreate returns an existing session for the user or starts a new one + // with the requested geometry. width/height of 0 means use the default. + GetOrCreate(username string, width, height uint16) (vncSession, error) + StopAll() +} + +// New creates a VNC server with the given screen capturer and input injector. +func New(capturer ScreenCapturer, injector InputInjector, password string) *Server { + return &Server{ + capturer: capturer, + injector: injector, + password: password, + authorizer: sshauth.NewAuthorizer(), + log: log.WithField("component", "vnc-server"), + } +} + +// SetServiceMode enables proxy-to-agent mode for Windows service operation. +func (s *Server) SetServiceMode(enabled bool) { + s.serviceMode = enabled +} + +// SetJWTConfig configures JWT authentication for VNC connections. +// Pass nil to disable JWT (public mode). +func (s *Server) SetJWTConfig(config *JWTConfig) { + s.mu.Lock() + defer s.mu.Unlock() + s.jwtConfig = config + s.jwtValidator = nil + s.jwtExtractor = nil +} + +// SetDisableAuth disables authentication entirely. +func (s *Server) SetDisableAuth(disable bool) { + s.disableAuth = disable +} + +// SetAgentToken sets a hex-encoded token that must be presented by incoming +// connections before any VNC data. Used in agent mode to verify that only the +// trusted service process connects. +func (s *Server) SetAgentToken(hexToken string) { + if hexToken == "" { + return + } + b, err := hex.DecodeString(hexToken) + if err != nil { + s.log.Warnf("invalid agent token: %v", err) + return + } + s.agentToken = b +} + +// SetNetstackNet sets the netstack network for userspace-only listening. +// When set, the VNC server listens via netstack instead of a real OS socket. +func (s *Server) SetNetstackNet(n *netstack.Net) { + s.mu.Lock() + defer s.mu.Unlock() + s.netstackNet = n +} + +// UpdateVNCAuth updates the fine-grained authorization configuration. +func (s *Server) UpdateVNCAuth(config *sshauth.Config) { + s.mu.Lock() + defer s.mu.Unlock() + s.jwtValidator = nil + s.jwtExtractor = nil + s.authorizer.Update(config) +} + +// Start begins listening for VNC connections on the given address. +// network is the NetBird overlay prefix used to validate connection sources. +func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.listener != nil { + return fmt.Errorf("server already running") + } + + if !network.IsValid() { + return fmt.Errorf("invalid overlay network prefix") + } + + s.ctx, s.cancel = context.WithCancel(ctx) + s.vmgr = s.platformSessionManager() + s.localAddr = addr.Addr() + s.network = network + + var listener net.Listener + var listenDesc string + if s.netstackNet != nil { + ln, err := s.netstackNet.ListenTCPAddrPort(addr) + if err != nil { + return fmt.Errorf("listen on netstack %s: %w", addr, err) + } + listener = ln + listenDesc = fmt.Sprintf("netstack %s", addr) + } else { + tcpAddr := net.TCPAddrFromAddrPort(addr) + ln, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", addr, err) + } + listener = ln + listenDesc = addr.String() + } + s.listener = listener + + if s.serviceMode { + s.platformInit() + } + + if s.serviceMode { + go s.serviceAcceptLoop() + } else { + go s.acceptLoop() + } + + s.log.Infof("started on %s (service_mode=%v)", listenDesc, s.serviceMode) + return nil +} + +// Stop shuts down the server and closes all connections. +func (s *Server) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + + if s.vmgr != nil { + s.vmgr.StopAll() + } + + if s.serviceMode { + s.platformShutdown() + } + + if c, ok := s.capturer.(interface{ Close() }); ok { + c.Close() + } + + if s.listener != nil { + err := s.listener.Close() + s.listener = nil + if err != nil { + return fmt.Errorf("close VNC listener: %w", err) + } + } + + s.log.Info("stopped") + return nil +} + +// acceptLoop handles VNC connections directly (user session mode). +func (s *Server) acceptLoop() { + for { + conn, err := s.listener.Accept() + if err != nil { + select { + case <-s.ctx.Done(): + return + default: + } + s.log.Debugf("accept VNC connection: %v", err) + continue + } + + go s.handleConnection(conn) + } +} + +func (s *Server) validateCapturer(capturer ScreenCapturer) error { + // Quick check first: if already ready, return immediately. + if capturer.Width() > 0 && capturer.Height() > 0 { + return nil + } + // Capturer not ready: poke any retry loop that supports it so it doesn't + // wait out its full backoff (e.g. macOS waiting for Screen Recording). + if w, ok := capturer.(interface{ Wake() }); ok { + w.Wake() + } + // Wait up to 5s for the capturer to become ready. + for range 50 { + time.Sleep(100 * time.Millisecond) + if capturer.Width() > 0 && capturer.Height() > 0 { + return nil + } + } + return errors.New("no display available (check X11 / framebuffer on Linux/FreeBSD or Screen Recording permission on macOS)") +} + +// isAllowedSource rejects connections from outside the NetBird overlay network +// and from the local WireGuard IP (prevents local privilege escalation). +// Matches the SSH server's connectionValidator logic. +func (s *Server) isAllowedSource(addr net.Addr) bool { + tcpAddr, ok := addr.(*net.TCPAddr) + if !ok { + s.log.Warnf("connection rejected: non-TCP address %s", addr) + return false + } + + remoteIP, ok := netip.AddrFromSlice(tcpAddr.IP) + if !ok { + s.log.Warnf("connection rejected: invalid remote IP %s", tcpAddr.IP) + return false + } + remoteIP = remoteIP.Unmap() + + if remoteIP.IsLoopback() && s.localAddr.IsLoopback() { + return true + } + + if remoteIP == s.localAddr { + s.log.Warnf("connection rejected from own IP %s", remoteIP) + return false + } + + if !s.network.IsValid() { + s.log.Warnf("connection rejected: overlay network not configured") + return false + } + if !s.network.Contains(remoteIP) { + s.log.Warnf("connection rejected from non-NetBird IP %s", remoteIP) + return false + } + + return true +} + +func (s *Server) handleConnection(conn net.Conn) { + connLog := s.log.WithField("remote", conn.RemoteAddr().String()) + + if !s.isAllowedSource(conn.RemoteAddr()) { + conn.Close() + return + } + if !s.verifyAgentToken(conn, connLog) { + return + } + header, err := readConnectionHeader(conn) + if err != nil { + connLog.Warnf("read connection header: %v", err) + conn.Close() + return + } + connLog, ok := s.authorizeJWT(conn, header, connLog) + if !ok { + return + } + + capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog) + if !ok { + return + } + defer sessionCleanup() + + if err := s.validateCapturer(capturer); err != nil { + rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("screen capturer: %v", err))) + connLog.Warnf("capturer not ready: %v", err) + return + } + + sess := &session{ + conn: conn, + capturer: capturer, + injector: injector, + serverW: capturer.Width(), + serverH: capturer.Height(), + password: s.password, + log: connLog, + } + sess.serve() +} + +// codeMessage formats a stable reject code with a human-readable message. +// Dashboards split on the first ": " to recover the code without parsing the +// free-text suffix. +func codeMessage(code, msg string) string { + return code + ": " + msg +} + +// jwtErrorCode maps a JWT auth error to a stable reject code. +func jwtErrorCode(err error) string { + if err == nil { + return RejectCodeJWTInvalid + } + if errors.Is(err, nbjwt.ErrTokenExpired) { + return RejectCodeJWTExpired + } + msg := err.Error() + switch { + case strings.Contains(msg, "JWT required but not provided"): + return RejectCodeJWTMissing + case strings.Contains(msg, "authorize") || strings.Contains(msg, "not authorized"): + return RejectCodeAuthForbidden + default: + return RejectCodeJWTInvalid + } +} + +// rejectConnection sends a minimal RFB handshake with a security failure +// reason, so VNC clients display the error message instead of a generic +// "unexpected disconnect." +func rejectConnection(conn net.Conn, reason string) { + defer conn.Close() + // RFB 3.8 server version. + if _, err := io.WriteString(conn, "RFB 003.008\n"); err != nil { + return + } + // Read client version (12 bytes), ignore errors here so a short-lived + // or pre-handshake client still gets the failure reason below. + var clientVer [12]byte + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _ = io.ReadFull(conn, clientVer[:]) + _ = conn.SetReadDeadline(time.Time{}) + // Send 0 security types = connection failed, followed by reason. + msg := []byte(reason) + buf := make([]byte, 1+4+len(msg)) + buf[0] = 0 // 0 security types = failure + binary.BigEndian.PutUint32(buf[1:5], uint32(len(msg))) + copy(buf[5:], msg) + _, _ = conn.Write(buf) +} + +const defaultJWTMaxTokenAge = 10 * 60 // 10 minutes + +// authenticateJWT validates the JWT from the connection header and checks +// authorization. For attach mode, just checks membership in the authorized +// user list. For session mode, additionally validates the OS user mapping. +func (s *Server) authenticateJWT(header *connectionHeader) (string, error) { + if header.jwt == "" { + return "", fmt.Errorf("JWT required but not provided") + } + + s.mu.Lock() + if err := s.ensureJWTValidator(); err != nil { + s.mu.Unlock() + return "", fmt.Errorf("initialize JWT validator: %w", err) + } + validator := s.jwtValidator + extractor := s.jwtExtractor + s.mu.Unlock() + + token, err := validator.ValidateAndParse(context.Background(), header.jwt) + if err != nil { + return "", fmt.Errorf("validate JWT: %w", err) + } + + if err := s.checkTokenAge(token); err != nil { + return "", err + } + + userAuth, err := extractor.ToUserAuth(token) + if err != nil { + return "", fmt.Errorf("extract user from JWT: %w", err) + } + if userAuth.UserId == "" { + return "", fmt.Errorf("JWT has no user ID") + } + + switch header.mode { + case ModeSession: + // Session mode: check user + OS username mapping. + if _, err := s.authorizer.Authorize(userAuth.UserId, header.username); err != nil { + return "", fmt.Errorf("authorize session for %s: %w", header.username, err) + } + default: + // Attach mode: just check user is in the authorized list (wildcard OS user). + if _, err := s.authorizer.Authorize(userAuth.UserId, "*"); err != nil { + return "", fmt.Errorf("user not authorized for VNC: %w", err) + } + } + + return userAuth.UserId, nil +} + +// ensureJWTValidator lazily initializes the JWT validator. Must be called with mu held. +func (s *Server) ensureJWTValidator() error { + if s.jwtValidator != nil && s.jwtExtractor != nil { + return nil + } + if s.jwtConfig == nil { + return fmt.Errorf("no JWT config") + } + + s.jwtValidator = nbjwt.NewValidator( + s.jwtConfig.Issuer, + s.jwtConfig.Audiences, + s.jwtConfig.KeysLocation, + false, + ) + + var opts []nbjwt.ClaimsExtractorOption + if len(s.jwtConfig.Audiences) > 0 { + opts = append(opts, nbjwt.WithAudience(s.jwtConfig.Audiences[0])) + } + if claim := s.authorizer.GetUserIDClaim(); claim != "" { + opts = append(opts, nbjwt.WithUserIDClaim(claim)) + } + s.jwtExtractor = nbjwt.NewClaimsExtractor(opts...) + + return nil +} + +func (s *Server) checkTokenAge(token *gojwt.Token) error { + maxAge := defaultJWTMaxTokenAge + if s.jwtConfig != nil && s.jwtConfig.MaxTokenAge > 0 { + maxAge = int(s.jwtConfig.MaxTokenAge) + } + return nbjwt.CheckTokenAge(token, time.Duration(maxAge)*time.Second) +} + +// readConnectionHeader reads the NetBird VNC session header from the connection. +// Format: [mode: 1 byte] [username_len: 2 bytes BE] [username: N bytes] +// +// [jwt_len: 2 bytes BE] [jwt: N bytes] +// +// Uses a short timeout: our WASM proxy sends the header immediately after +// connecting. Standard VNC clients don't send anything first (server speaks +// first in RFB), so they time out and get the default attach mode. +func readConnectionHeader(conn net.Conn) (*connectionHeader, error) { + if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + return nil, fmt.Errorf("set deadline: %w", err) + } + defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck + + var hdr [3]byte + if _, err := io.ReadFull(conn, hdr[:]); err != nil { + // Timeout or error: assume no header, use attach mode. + return &connectionHeader{mode: ModeAttach}, nil + } + + // Restore a longer deadline for reading variable-length fields. + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + return nil, fmt.Errorf("set deadline: %w", err) + } + + mode := hdr[0] + usernameLen := binary.BigEndian.Uint16(hdr[1:3]) + + var username string + if usernameLen > 0 { + if usernameLen > 256 { + return nil, fmt.Errorf("username too long: %d", usernameLen) + } + buf := make([]byte, usernameLen) + if _, err := io.ReadFull(conn, buf); err != nil { + return nil, fmt.Errorf("read username: %w", err) + } + username = string(buf) + } + + // Read JWT token length and data. + var jwtLenBuf [2]byte + var jwtToken string + if _, err := io.ReadFull(conn, jwtLenBuf[:]); err == nil { + jwtLen := binary.BigEndian.Uint16(jwtLenBuf[:]) + if jwtLen >= 8192 { + return nil, fmt.Errorf("jwt too long: %d (max 8191)", jwtLen) + } + if jwtLen > 0 { + buf := make([]byte, jwtLen) + if _, err := io.ReadFull(conn, buf); err != nil { + return nil, fmt.Errorf("read JWT: %w", err) + } + jwtToken = string(buf) + } + } + + // Read optional Windows session ID (4 bytes BE). Missing = 0 (console/auto). + var sessionID uint32 + var sidBuf [4]byte + if _, err := io.ReadFull(conn, sidBuf[:]); err == nil { + sessionID = binary.BigEndian.Uint32(sidBuf[:]) + } + + // Read optional requested viewport size (2x uint16 BE). Missing = 0 (default). + var width, height uint16 + var geomBuf [4]byte + if _, err := io.ReadFull(conn, geomBuf[:]); err == nil { + width = binary.BigEndian.Uint16(geomBuf[0:2]) + height = binary.BigEndian.Uint16(geomBuf[2:4]) + } + + return &connectionHeader{ + mode: mode, + username: username, + jwt: jwtToken, + sessionID: sessionID, + width: width, + height: height, + }, nil +} + +// verifyAgentToken validates the agent token prefix when configured. Returns +// false when the token is invalid or unreadable; the connection is closed. +func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool { + if len(s.agentToken) == 0 { + return true + } + buf := make([]byte, len(s.agentToken)) + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + connLog.Debugf("set agent token deadline: %v", err) + conn.Close() + return false + } + if _, err := io.ReadFull(conn, buf); err != nil { + connLog.Warnf("agent auth: read token: %v", err) + conn.Close() + return false + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + connLog.Debugf("clear agent token deadline: %v", err) + } + if subtle.ConstantTimeCompare(buf, s.agentToken) != 1 { + connLog.Warn("agent auth: invalid token, rejecting") + conn.Close() + return false + } + return true +} + +// authorizeJWT performs JWT validation when auth is enabled. Returns the +// enriched log entry and ok=false if the connection was rejected. +func (s *Server) authorizeJWT(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, bool) { + if s.disableAuth { + return connLog, true + } + if s.jwtConfig == nil { + rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) + connLog.Warn("auth rejected: no identity provider configured") + return connLog, false + } + jwtUserID, err := s.authenticateJWT(header) + if err != nil { + rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) + connLog.Warnf("auth rejected: %v", err) + return connLog, false + } + return connLog.WithField("jwt_user", jwtUserID), true +} + +// acquireSessionResources returns the capturer/injector to use for this +// connection and a cleanup func to call when the session ends. ok is false +// when the connection was rejected (and the caller must just return). +func (s *Server) acquireSessionResources(conn net.Conn, header *connectionHeader, connLog **log.Entry) (ScreenCapturer, InputInjector, func(), bool) { + switch header.mode { + case ModeSession: + return s.acquireVirtualSession(conn, header, connLog) + default: + return s.acquireAttachSession(), s.injector, attachSessionCleanup, true + } +} + +func (s *Server) acquireVirtualSession(conn net.Conn, header *connectionHeader, connLog **log.Entry) (ScreenCapturer, InputInjector, func(), bool) { + if s.vmgr == nil { + rejectConnection(conn, codeMessage(RejectCodeUnsupportedOS, "virtual sessions not supported on this platform")) + (*connLog).Warn("session rejected: not supported on this platform") + return nil, nil, nil, false + } + if header.username == "" { + rejectConnection(conn, codeMessage(RejectCodeBadRequest, "session mode requires a username")) + (*connLog).Warn("session rejected: no username provided") + return nil, nil, nil, false + } + vs, err := s.vmgr.GetOrCreate(header.username, header.width, header.height) + if err != nil { + rejectConnection(conn, codeMessage(RejectCodeSessionError, fmt.Sprintf("create virtual session: %v", err))) + (*connLog).Warnf("create virtual session for %s: %v", header.username, err) + return nil, nil, nil, false + } + vs.ClientConnect() + *connLog = (*connLog).WithField("vnc_user", header.username) + (*connLog).Infof("session mode: user=%s display=%s", header.username, vs.Display()) + return vs.Capturer(), vs.Injector(), vs.ClientDisconnect, true +} + +func (s *Server) acquireAttachSession() ScreenCapturer { + if cc, ok := s.capturer.(interface{ ClientConnect() }); ok { + cc.ClientConnect() + } + return s.capturer +} + +// attachSessionCleanup is the no-op cleanup used by attach mode. Returned as a +// named func rather than an inline closure so the empty body is unambiguous. +func attachSessionCleanup() { + // Attach mode keeps the shared capturer; nothing to release per session. +} diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go new file mode 100644 index 00000000000..1217042228a --- /dev/null +++ b/client/vnc/server/server_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin && !ios + +package server + +func (s *Server) platformInit() { + // no-op on macOS +} + +// serviceAcceptLoop is not supported on macOS. +func (s *Server) serviceAcceptLoop() { + s.log.Warn("service mode not supported on macOS, falling back to direct mode") + s.acceptLoop() +} + +func (s *Server) platformSessionManager() virtualSessionManager { + return nil +} + +func (s *Server) platformShutdown() { + // no-op on this platform +} diff --git a/client/vnc/server/server_stub.go b/client/vnc/server/server_stub.go new file mode 100644 index 00000000000..e6ace1a2737 --- /dev/null +++ b/client/vnc/server/server_stub.go @@ -0,0 +1,21 @@ +//go:build (!windows && !darwin && !freebsd && !(linux && !android)) || (darwin && ios) + +package server + +func (s *Server) platformInit() { + // no-op on unsupported platforms +} + +// serviceAcceptLoop is not supported on non-Windows platforms. +func (s *Server) serviceAcceptLoop() { + s.log.Warn("service mode not supported on this platform, falling back to direct mode") + s.acceptLoop() +} + +func (s *Server) platformSessionManager() virtualSessionManager { + return nil +} + +func (s *Server) platformShutdown() { + // no-op on this platform +} diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go new file mode 100644 index 00000000000..6467aacc85b --- /dev/null +++ b/client/vnc/server/server_test.go @@ -0,0 +1,412 @@ +package server + +import ( + "encoding/binary" + "encoding/hex" + "image" + "io" + "net" + "net/netip" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testCapturer returns a 100x100 image for test sessions. +type testCapturer struct{} + +func (t *testCapturer) Width() int { return 100 } +func (t *testCapturer) Height() int { return 100 } +func (t *testCapturer) Capture() (*image.RGBA, error) { + return image.NewRGBA(image.Rect(0, 0, 100, 100)), nil +} + +func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net.Addr, *Server) { + t.Helper() + + srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv.SetDisableAuth(disableAuth) + if jwtConfig != nil { + srv.SetJWTConfig(jwtConfig) + } + + addr := netip.MustParseAddrPort("127.0.0.1:0") + network := netip.MustParsePrefix("127.0.0.0/8") + require.NoError(t, srv.Start(t.Context(), addr, network)) + // Override local address so source validation doesn't reject 127.0.0.1 as "own IP". + srv.localAddr = netip.MustParseAddr("10.99.99.1") + t.Cleanup(func() { _ = srv.Stop() }) + + return srv.listener.Addr(), srv +} + +func TestAuthEnabled_NoJWTConfig_RejectsConnection(t *testing.T) { + addr, _ := startTestServer(t, false, nil) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + // Send session header: attach mode, no username, no JWT. + header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 + header[0] = ModeAttach + _, err = conn.Write(header) + require.NoError(t, err) + + // Server should send RFB version then security failure. + var version [12]byte + _, err = io.ReadFull(conn, version[:]) + require.NoError(t, err) + assert.Equal(t, "RFB 003.008\n", string(version[:])) + + // Write client version to proceed through handshake. + _, err = conn.Write(version[:]) + require.NoError(t, err) + + // Read security types: 0 means failure, followed by reason. + var numTypes [1]byte + _, err = io.ReadFull(conn, numTypes[:]) + require.NoError(t, err) + assert.Equal(t, byte(0), numTypes[0], "should have 0 security types (failure)") + + var reasonLen [4]byte + _, err = io.ReadFull(conn, reasonLen[:]) + require.NoError(t, err) + + reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:])) + _, err = io.ReadFull(conn, reason) + require.NoError(t, err) + assert.Contains(t, string(reason), "identity provider", "rejection reason should mention missing IdP config") +} + +func TestAuthDisabled_AllowsConnection(t *testing.T) { + addr, _ := startTestServer(t, true, nil) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + // Send session header: attach mode, no username, no JWT. + header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 + header[0] = ModeAttach + _, err = conn.Write(header) + require.NoError(t, err) + + // Server should send RFB version. + var version [12]byte + _, err = io.ReadFull(conn, version[:]) + require.NoError(t, err) + assert.Equal(t, "RFB 003.008\n", string(version[:])) + + // Write client version. + _, err = conn.Write(version[:]) + require.NoError(t, err) + + // Should get security types (not 0 = failure). + var numTypes [1]byte + _, err = io.ReadFull(conn, numTypes[:]) + require.NoError(t, err) + assert.NotEqual(t, byte(0), numTypes[0], "should have at least one security type (auth disabled)") +} + +// TestAuthEnabled_InvalidJWT_RejectedBeforeRFB confirms the VNC server itself +// (not just the JWT library) wires authentication into handleConnection. A +// well-formed JWT-shaped token must hit the server's validation path and be +// rejected with an AUTH_JWT_* reason, never reaching the RFB handshake. +func TestAuthEnabled_InvalidJWT_RejectedBeforeRFB(t *testing.T) { + addr, _ := startTestServer(t, false, &JWTConfig{ + Issuer: "https://example.invalid", + KeysLocation: "https://example.invalid/.well-known/jwks.json", + Audiences: []string{"test"}, + }) + + // Three-segment "JWT" with bogus base64. The server's authenticateJWT path + // must catch this regardless of the IdP being unreachable. + bogusJWT := "abc.def.ghi" + header := make([]byte, 3+2+len(bogusJWT)+4+4) + header[0] = ModeAttach + binary.BigEndian.PutUint16(header[1:3], 0) // username len + binary.BigEndian.PutUint16(header[3:5], uint16(len(bogusJWT))) + copy(header[5:5+len(bogusJWT)], bogusJWT) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = conn.Write(header) + require.NoError(t, err) + + var version [12]byte + _, err = io.ReadFull(conn, version[:]) + require.NoError(t, err) + _, err = conn.Write(version[:]) + require.NoError(t, err) + + var numTypes [1]byte + _, err = io.ReadFull(conn, numTypes[:]) + require.NoError(t, err) + require.Equal(t, byte(0), numTypes[0], "must fail security negotiation") + + var reasonLen [4]byte + _, err = io.ReadFull(conn, reasonLen[:]) + require.NoError(t, err) + reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:])) + _, err = io.ReadFull(conn, reason) + require.NoError(t, err) + // The reason must carry one of the server's AUTH_JWT_* codes, proving + // the rejection came from authenticateJWT in handleConnection. + r := string(reason) + hasJWTReject := false + for _, code := range []string{RejectCodeJWTInvalid, RejectCodeJWTExpired, RejectCodeAuthForbidden} { + if strings.Contains(r, code) { + hasJWTReject = true + break + } + } + assert.True(t, hasJWTReject, "reason %q must include an AUTH_JWT_* code", r) +} + +// TestAuth_NoUnauthBytesPastHeader proves the server does not send any RFB +// content to a connection that fails source validation. Specifically, the +// server must close immediately and the client must see EOF before any RFB +// version greeting is written. +func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { + srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv.SetDisableAuth(true) + addr := netip.MustParseAddrPort("127.0.0.1:0") + // Tight overlay that excludes 127.0.0.0/8 and a non-loopback local IP, so + // the loopback short-circuit in isAllowedSource doesn't apply. + require.NoError(t, srv.Start(t.Context(), addr, netip.MustParsePrefix("10.99.0.0/16"))) + srv.localAddr = netip.MustParseAddr("10.99.99.1") + t.Cleanup(func() { _ = srv.Stop() }) + + conn, err := net.Dial("tcp", srv.listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + // Reading even one byte must EOF: the source IP (127.0.0.1) is outside + // the configured overlay, so handleConnection closes before writing. + var b [1]byte + _, err = io.ReadFull(conn, b[:]) + require.Error(t, err, "non-overlay client must see EOF, not an RFB greeting") +} + +func TestAuthEnabled_EmptyJWT_Rejected(t *testing.T) { + // Auth enabled with a (bogus) JWT config: connections without JWT should be rejected. + addr, _ := startTestServer(t, false, &JWTConfig{ + Issuer: "https://example.com", + KeysLocation: "https://example.com/.well-known/jwks.json", + Audiences: []string{"test"}, + }) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + // Send session header with empty JWT. + header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 + header[0] = ModeAttach + _, err = conn.Write(header) + require.NoError(t, err) + + var version [12]byte + _, err = io.ReadFull(conn, version[:]) + require.NoError(t, err) + + _, err = conn.Write(version[:]) + require.NoError(t, err) + + var numTypes [1]byte + _, err = io.ReadFull(conn, numTypes[:]) + require.NoError(t, err) + assert.Equal(t, byte(0), numTypes[0], "should reject with 0 security types") +} + +func TestIsAllowedSource(t *testing.T) { + tests := []struct { + name string + localAddr netip.Addr + network netip.Prefix + remote net.Addr + want bool + }{ + { + name: "non-tcp address rejected", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + remote: &net.UDPAddr{IP: net.ParseIP("10.99.99.2"), Port: 1234}, + want: false, + }, + { + name: "own IP rejected", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + remote: &net.TCPAddr{IP: net.ParseIP("10.99.99.1"), Port: 5900}, + want: false, + }, + { + name: "non-overlay IP rejected", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + remote: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 5900}, + want: false, + }, + { + name: "overlay IP allowed", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + remote: &net.TCPAddr{IP: net.ParseIP("10.99.99.2"), Port: 5900}, + want: true, + }, + { + name: "v4-mapped v6 in overlay allowed (unmapped)", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + remote: &net.TCPAddr{IP: net.ParseIP("::ffff:10.99.99.2"), Port: 5900}, + want: true, + }, + { + name: "loopback allowed only when local is loopback", + localAddr: netip.MustParseAddr("127.0.0.1"), + network: netip.MustParsePrefix("127.0.0.0/8"), + remote: &net.TCPAddr{IP: net.ParseIP("127.0.0.5"), Port: 5900}, + want: true, + }, + { + name: "invalid network rejected (fail-closed)", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.Prefix{}, + remote: &net.TCPAddr{IP: net.ParseIP("10.99.99.2"), Port: 5900}, + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv.localAddr = tc.localAddr + srv.network = tc.network + assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote)) + }) + } +} + +func TestStart_InvalidNetworkRejected(t *testing.T) { + srv := New(&testCapturer{}, &StubInputInjector{}, "") + addr := netip.MustParseAddrPort("127.0.0.1:0") + err := srv.Start(t.Context(), addr, netip.Prefix{}) + require.Error(t, err, "Start must refuse an invalid overlay prefix") + assert.Contains(t, err.Error(), "invalid overlay network prefix") +} + +func TestAgentToken_MismatchClosesConnection(t *testing.T) { + srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv.SetDisableAuth(true) + srv.SetAgentToken("deadbeefcafebabe") + + addr := netip.MustParseAddrPort("127.0.0.1:0") + network := netip.MustParsePrefix("127.0.0.0/8") + require.NoError(t, srv.Start(t.Context(), addr, network)) + srv.localAddr = netip.MustParseAddr("10.99.99.1") + t.Cleanup(func() { _ = srv.Stop() }) + + conn, err := net.Dial("tcp", srv.listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + // Send a wrong token of the right length (8 bytes hex-decoded). + if _, err := conn.Write([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}); err != nil { + // Server may already have closed; either way the read below must EOF. + _ = err + } + + // Server must close without sending the RFB greeting. + var version [12]byte + _, err = io.ReadFull(conn, version[:]) + require.Error(t, err, "server must close the connection on bad agent token") +} + +func TestAgentToken_MatchAllowsHandshake(t *testing.T) { + srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv.SetDisableAuth(true) + const tokenHex = "deadbeefcafebabe" + srv.SetAgentToken(tokenHex) + token, err := hex.DecodeString(tokenHex) + require.NoError(t, err) + + addr := netip.MustParseAddrPort("127.0.0.1:0") + network := netip.MustParsePrefix("127.0.0.0/8") + require.NoError(t, srv.Start(t.Context(), addr, network)) + srv.localAddr = netip.MustParseAddr("10.99.99.1") + t.Cleanup(func() { _ = srv.Stop() }) + + conn, err := net.Dial("tcp", srv.listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = conn.Write(token) + require.NoError(t, err) + + // Send session header so handleConnection can proceed past readConnectionHeader. + header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 + header[0] = ModeAttach + _, err = conn.Write(header) + require.NoError(t, err) + + // With a matching token the server proceeds to the RFB greeting. + var version [12]byte + _, err = io.ReadFull(conn, version[:]) + require.NoError(t, err, "server must keep the connection open after a valid agent token") + assert.Equal(t, "RFB 003.008\n", string(version[:])) +} + +func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) { + // Default platformSessionManager() on non-Linux returns nil, so ModeSession + // must be rejected with the UNSUPPORTED reason rather than crashing. + srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv.SetDisableAuth(true) + + addr := netip.MustParseAddrPort("127.0.0.1:0") + network := netip.MustParsePrefix("127.0.0.0/8") + require.NoError(t, srv.Start(t.Context(), addr, network)) + srv.localAddr = netip.MustParseAddr("10.99.99.1") + // Force vmgr to nil regardless of platform so the test is deterministic. + srv.vmgr = nil + t.Cleanup(func() { _ = srv.Stop() }) + + conn, err := net.Dial("tcp", srv.listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + // ModeSession with no username/JWT, so we exit on the vmgr==nil branch + // before username validation runs. + header := []byte{ModeSession, 0, 0, 0, 0} + _, err = conn.Write(header) + require.NoError(t, err) + + var version [12]byte + _, err = io.ReadFull(conn, version[:]) + require.NoError(t, err) + _, err = conn.Write(version[:]) + require.NoError(t, err) + + var numTypes [1]byte + _, err = io.ReadFull(conn, numTypes[:]) + require.NoError(t, err) + assert.Equal(t, byte(0), numTypes[0]) + + var reasonLen [4]byte + _, err = io.ReadFull(conn, reasonLen[:]) + require.NoError(t, err) + reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:])) + _, err = io.ReadFull(conn, reason) + require.NoError(t, err) + assert.Contains(t, string(reason), RejectCodeUnsupportedOS) +} diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go new file mode 100644 index 00000000000..97d6b539345 --- /dev/null +++ b/client/vnc/server/server_windows.go @@ -0,0 +1,312 @@ +//go:build windows + +package server + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +var ( + sasDLL = windows.NewLazySystemDLL("sas.dll") + procSendSAS = sasDLL.NewProc("SendSAS") + + procConvertStringSecurityDescriptorToSecurityDescriptor = advapi32.NewProc("ConvertStringSecurityDescriptorToSecurityDescriptorW") +) + +// sasSecurityAttributes builds a SECURITY_ATTRIBUTES that grants +// EVENT_MODIFY_STATE only to the SYSTEM account, preventing unprivileged +// local processes from triggering the Secure Attention Sequence. +func sasSecurityAttributes() (*windows.SecurityAttributes, error) { + // SDDL: grant full access to SYSTEM (creates/waits) and EVENT_MODIFY_STATE + // to the interactive user (IU) so the VNC agent in the console session can + // signal it. Other local users and network users are denied. + sddl, err := windows.UTF16PtrFromString("D:(A;;GA;;;SY)(A;;0x0002;;;IU)") + if err != nil { + return nil, err + } + var sd uintptr + r, _, lerr := procConvertStringSecurityDescriptorToSecurityDescriptor.Call( + uintptr(unsafe.Pointer(sddl)), + 1, // SDDL_REVISION_1 + uintptr(unsafe.Pointer(&sd)), + 0, + ) + if r == 0 { + return nil, lerr + } + return &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: (*windows.SECURITY_DESCRIPTOR)(unsafe.Pointer(sd)), + InheritHandle: 0, + }, nil +} + +// sasOriginalState tracks the SoftwareSASGeneration value present before we +// changed it, so disableSoftwareSAS can restore the machine to its prior +// state on shutdown instead of leaving the policy enabled. +type sasOriginalState struct { + had bool // true if the value existed before we wrote + value uint32 // its prior DWORD value, if had == true +} + +var savedSASState sasOriginalState + +// enableSoftwareSAS sets the SoftwareSASGeneration registry key to allow +// services to trigger the Secure Attention Sequence via SendSAS. Without this, +// SendSAS silently does nothing on most Windows editions. The original value +// is snapshotted so disableSoftwareSAS can put the system back as it was. +func enableSoftwareSAS() { + key, _, err := registry.CreateKey( + registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`, + registry.SET_VALUE|registry.QUERY_VALUE, + ) + if err != nil { + log.Warnf("open SoftwareSASGeneration registry key: %v", err) + return + } + defer key.Close() + + if prev, _, err := key.GetIntegerValue("SoftwareSASGeneration"); err == nil { + savedSASState = sasOriginalState{had: true, value: uint32(prev)} + } else { + savedSASState = sasOriginalState{had: false} + } + + if err := key.SetDWordValue("SoftwareSASGeneration", 1); err != nil { + log.Warnf("set SoftwareSASGeneration: %v", err) + return + } + log.Debug("SoftwareSASGeneration registry key set to 1 (services allowed)") +} + +// disableSoftwareSAS restores the SoftwareSASGeneration value to its +// pre-enable state. Idempotent; safe to call when enableSoftwareSAS never ran. +func disableSoftwareSAS() { + key, err := registry.OpenKey( + registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`, + registry.SET_VALUE, + ) + if err != nil { + log.Debugf("open SoftwareSASGeneration for restore: %v", err) + return + } + defer key.Close() + + if savedSASState.had { + if err := key.SetDWordValue("SoftwareSASGeneration", savedSASState.value); err != nil { + log.Warnf("restore SoftwareSASGeneration to %d: %v", savedSASState.value, err) + } + return + } + if err := key.DeleteValue("SoftwareSASGeneration"); err != nil { + log.Debugf("delete SoftwareSASGeneration: %v", err) + } +} + +// startSASListener creates a named event with a restricted DACL and waits for +// the VNC input injector to signal it. When signaled, it calls SendSAS(FALSE) +// from Session 0 to trigger the Secure Attention Sequence (Ctrl+Alt+Del). +// Only SYSTEM processes can open the event. +// +// sas.dll / SendSAS is part of the Desktop Experience feature: present on +// client SKUs (Win10/11) and Server SKUs with Desktop Experience installed, +// missing on Server Core. We probe for the symbol at startup; if absent we +// don't register the listener and the agent will silently drop SAS keysyms, +// rather than panicking the entire service every time the user clicks +// Ctrl+Alt+Del. +func startSASListener(ctx context.Context) { + ev, ok := createSASEvent() + if !ok { + return + } + log.Info("SAS listener ready (Session 0)") + go runSASListenerLoop(ctx, ev) +} + +// createSASEvent prepares the named event handle on which the SAS listener +// waits for client signals. Returns ok=false (with the failure already +// logged) when the platform doesn't support SAS or the event cannot be +// created; the caller must not spawn the listener goroutine in that case. +func createSASEvent() (windows.Handle, bool) { + if err := procSendSAS.Find(); err != nil { + log.Warnf("SAS unavailable on this Windows SKU (sas.dll/SendSAS not present): %v", err) + return 0, false + } + enableSoftwareSAS() + namePtr, err := windows.UTF16PtrFromString(sasEventName) + if err != nil { + log.Warnf("SAS listener UTF16: %v", err) + return 0, false + } + sa, err := sasSecurityAttributes() + if err != nil { + log.Warnf("build SAS security descriptor: %v", err) + return 0, false + } + ev, err := windows.CreateEvent(sa, 0, 0, namePtr) + if err != nil { + log.Warnf("SAS CreateEvent: %v", err) + return 0, false + } + return ev, true +} + +// runSASListenerLoop blocks on ev and invokes SendSAS each time it is +// signalled, until ctx is cancelled. Recovers from panics inside SendSAS so +// a future ABI surprise doesn't tear down the service. +func runSASListenerLoop(ctx context.Context, ev windows.Handle) { + defer windows.CloseHandle(ev) + defer func() { + if r := recover(); r != nil { + log.Warnf("SAS listener recovered from panic: %v", r) + } + }() + const pollMillis = 500 + for { + if ctx.Err() != nil { + return + } + ret, _ := windows.WaitForSingleObject(ev, pollMillis) + if ret != windows.WAIT_OBJECT_0 { + continue + } + r, _, sasErr := procSendSAS.Call(0) // FALSE = not from service desktop + if r == 0 { + log.Warnf("SendSAS: %v", sasErr) + continue + } + log.Info("SendSAS called from Session 0") + } +} + +// enablePrivilege enables a named privilege on the current process token. +func enablePrivilege(name string) error { + var token windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), + windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, &token); err != nil { + return err + } + defer token.Close() + + var luid windows.LUID + namePtr, err := windows.UTF16PtrFromString(name) + if err != nil { + return fmt.Errorf("UTF16 privilege name: %w", err) + } + if err := windows.LookupPrivilegeValue(nil, namePtr, &luid); err != nil { + return err + } + tp := windows.Tokenprivileges{PrivilegeCount: 1} + tp.Privileges[0].Luid = luid + tp.Privileges[0].Attributes = windows.SE_PRIVILEGE_ENABLED + return windows.AdjustTokenPrivileges(token, false, &tp, 0, nil, nil) +} + +func (s *Server) platformSessionManager() virtualSessionManager { + return nil +} + +// platformShutdown restores any machine state mutated by platformInit. +func (s *Server) platformShutdown() { + disableSoftwareSAS() +} + +// platformInit starts the SAS listener and enables privileges needed for +// Session 0 operations (agent spawning, SendSAS). +func (s *Server) platformInit() { + for _, priv := range []string{"SeTcbPrivilege", "SeAssignPrimaryTokenPrivilege"} { + if err := enablePrivilege(priv); err != nil { + log.Debugf("enable %s: %v", priv, err) + } + } + startSASListener(s.ctx) +} + +// serviceAcceptLoop runs in Session 0. It validates source IP and +// authenticates via JWT before proxying connections to the user-session agent. +func (s *Server) serviceAcceptLoop() { + + sm := newSessionManager(agentPort) + go sm.run() + + log.Infof("service mode, proxying connections to agent on 127.0.0.1:%s", agentPort) + + for { + conn, err := s.listener.Accept() + if err != nil { + select { + case <-s.ctx.Done(): + sm.Stop() + return + default: + } + s.log.Debugf("accept VNC connection: %v", err) + continue + } + + go s.handleServiceConnection(conn, sm) + } +} + +// handleServiceConnection validates the source IP and JWT, then proxies +// the connection (with header bytes replayed) to the agent. +func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) { + connLog := s.log.WithField("remote", conn.RemoteAddr().String()) + + if !s.isAllowedSource(conn.RemoteAddr()) { + conn.Close() + return + } + + var headerBuf bytes.Buffer + tee := io.TeeReader(conn, &headerBuf) + teeConn := &prefixConn{Reader: tee, Conn: conn} + + header, err := readConnectionHeader(teeConn) + if err != nil { + connLog.Debugf("read connection header: %v", err) + conn.Close() + return + } + + if !s.disableAuth { + if s.jwtConfig == nil { + rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) + connLog.Warn("auth rejected: no identity provider configured") + return + } + if _, err := s.authenticateJWT(header); err != nil { + rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) + connLog.Warnf("auth rejected: %v", err) + return + } + } + + // Replay buffered header bytes + remaining stream to the agent. + replayConn := &prefixConn{ + Reader: io.MultiReader(&headerBuf, conn), + Conn: conn, + } + proxyToAgent(replayConn, agentPort, sm.AuthToken()) +} + +// prefixConn wraps a net.Conn, overriding Read to use a different reader. +type prefixConn struct { + io.Reader + net.Conn +} + +func (p *prefixConn) Read(b []byte) (int, error) { + return p.Reader.Read(b) +} diff --git a/client/vnc/server/server_x11.go b/client/vnc/server/server_x11.go new file mode 100644 index 00000000000..6c0b6b643e8 --- /dev/null +++ b/client/vnc/server/server_x11.go @@ -0,0 +1,21 @@ +//go:build (linux && !android) || freebsd + +package server + +func (s *Server) platformInit() { + // no-op on X11 +} + +// serviceAcceptLoop is not supported on Linux. +func (s *Server) serviceAcceptLoop() { + s.log.Warn("service mode not supported on Linux, falling back to direct mode") + s.acceptLoop() +} + +func (s *Server) platformSessionManager() virtualSessionManager { + return newSessionManager(s.log) +} + +func (s *Server) platformShutdown() { + // no-op on this platform +} diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go new file mode 100644 index 00000000000..08d37ee135a --- /dev/null +++ b/client/vnc/server/session.go @@ -0,0 +1,672 @@ +package server + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "image" + "io" + "net" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + readDeadline = 60 * time.Second + maxCutTextBytes = 1 << 20 // 1 MiB +) + +const tileSize = 64 // pixels per tile for dirty-rect detection + +// fullFramePromoteNum/Den trigger full-frame encoding when the dirty area +// exceeds num/den of the screen. Once past the crossover (benchmarks put it +// around 60% at 1080p) a single zlib rect is faster than many per-tile +// encodes AND produces about the same wire bytes: the per-tile path keeps +// restarting zlib dictionaries and re-emitting rect headers. +const ( + fullFramePromoteNum = 60 + fullFramePromoteDen = 100 +) + +type session struct { + conn net.Conn + capturer ScreenCapturer + injector InputInjector + serverW int + serverH int + password string + log *log.Entry + + writeMu sync.Mutex + // pf and useZlib/zlib are written by messageLoop before the first FB + // update request arrives (SetPixelFormat/SetEncodings happen during the + // client handshake), and only read from the encoder goroutine. Fine + // without locks because of that ordering invariant. + pf clientPixelFormat + useZlib bool + useHextile bool + useTight bool + zlib *zlibState + tight *tightState + // prevFrame, curFrame and idleFrames live on the encoder goroutine and + // must not be touched elsewhere. curFrame holds a session-owned copy of + // the capturer's latest frame so the encoder works on a stable buffer + // even when the capturer double-buffers and recycles memory underneath. + prevFrame *image.RGBA + curFrame *image.RGBA + idleFrames int + + // encodeCh carries framebuffer-update requests from the read loop to the + // encoder goroutine. Buffered size 1: RFB clients have one outstanding + // request at a time, so a new request always replaces any pending one. + encodeCh chan fbRequest +} + +type fbRequest struct { + incremental bool +} + +func (s *session) addr() string { return s.conn.RemoteAddr().String() } + +// serve runs the full RFB session lifecycle. +func (s *session) serve() { + defer s.conn.Close() + s.pf = defaultClientPixelFormat() + s.encodeCh = make(chan fbRequest, 1) + + if err := s.handshake(); err != nil { + s.log.Warnf("handshake with %s: %v", s.addr(), err) + return + } + s.log.Infof("client connected: %s", s.addr()) + + done := make(chan struct{}) + defer close(done) + go s.clipboardPoll(done) + + encoderDone := make(chan struct{}) + go s.encoderLoop(encoderDone) + defer func() { + close(s.encodeCh) + <-encoderDone + }() + + if err := s.messageLoop(); err != nil && err != io.EOF { + s.log.Warnf("client %s disconnected: %v", s.addr(), err) + } else { + s.log.Infof("client disconnected: %s", s.addr()) + } +} + +// clipboardPoll periodically checks the server-side clipboard and sends +// changes to the VNC client. Only runs during active sessions. +func (s *session) clipboardPoll(done <-chan struct{}) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + var lastClip string + for { + select { + case <-done: + return + case <-ticker.C: + text := s.injector.GetClipboard() + if len(text) > maxCutTextBytes { + text = text[:maxCutTextBytes] + } + if text != "" && text != lastClip { + lastClip = text + if err := s.sendServerCutText(text); err != nil { + s.log.Debugf("send clipboard to client: %v", err) + return + } + } + } + } +} + +func (s *session) handshake() error { + // Send protocol version. + if _, err := io.WriteString(s.conn, rfbProtocolVersion); err != nil { + return fmt.Errorf("send version: %w", err) + } + + // Read client version. + var clientVer [12]byte + if _, err := io.ReadFull(s.conn, clientVer[:]); err != nil { + return fmt.Errorf("read client version: %w", err) + } + + // Send supported security types. + if err := s.sendSecurityTypes(); err != nil { + return err + } + + // Read chosen security type. + var secType [1]byte + if _, err := io.ReadFull(s.conn, secType[:]); err != nil { + return fmt.Errorf("read security type: %w", err) + } + + if err := s.handleSecurity(secType[0]); err != nil { + return err + } + + // Read ClientInit. + var clientInit [1]byte + if _, err := io.ReadFull(s.conn, clientInit[:]); err != nil { + return fmt.Errorf("read ClientInit: %w", err) + } + + return s.sendServerInit() +} + +func (s *session) sendSecurityTypes() error { + if s.password == "" { + _, err := s.conn.Write([]byte{1, secNone}) + return err + } + _, err := s.conn.Write([]byte{1, secVNCAuth}) + return err +} + +func (s *session) handleSecurity(secType byte) error { + switch secType { + case secVNCAuth: + return s.doVNCAuth() + case secNone: + return binary.Write(s.conn, binary.BigEndian, uint32(0)) + default: + return fmt.Errorf("unsupported security type: %d", secType) + } +} + +func (s *session) doVNCAuth() error { + challenge := make([]byte, 16) + if _, err := rand.Read(challenge); err != nil { + return fmt.Errorf("generate challenge: %w", err) + } + if _, err := s.conn.Write(challenge); err != nil { + return fmt.Errorf("send challenge: %w", err) + } + + response := make([]byte, 16) + if _, err := io.ReadFull(s.conn, response); err != nil { + return fmt.Errorf("read auth response: %w", err) + } + + var result uint32 + if s.password != "" { + expected, err := vncAuthEncrypt(challenge, s.password) + if err != nil { + return fmt.Errorf("vnc auth encrypt: %w", err) + } + if !bytes.Equal(expected, response) { + result = 1 + } + } + + if err := binary.Write(s.conn, binary.BigEndian, result); err != nil { + return fmt.Errorf("send auth result: %w", err) + } + if result != 0 { + msg := "authentication failed" + _ = binary.Write(s.conn, binary.BigEndian, uint32(len(msg))) + _, _ = s.conn.Write([]byte(msg)) + return fmt.Errorf("authentication failed from %s", s.addr()) + } + return nil +} + +func (s *session) sendServerInit() error { + name := []byte("NetBird VNC") + buf := make([]byte, 0, 4+16+4+len(name)) + + // Framebuffer width and height. + buf = append(buf, byte(s.serverW>>8), byte(s.serverW)) + buf = append(buf, byte(s.serverH>>8), byte(s.serverH)) + + // Server pixel format. + buf = append(buf, serverPixelFormat[:]...) + + // Desktop name. + buf = append(buf, + byte(len(name)>>24), byte(len(name)>>16), + byte(len(name)>>8), byte(len(name)), + ) + buf = append(buf, name...) + + _, err := s.conn.Write(buf) + return err +} + +func (s *session) messageLoop() error { + for { + var msgType [1]byte + if err := s.conn.SetDeadline(time.Now().Add(readDeadline)); err != nil { + return fmt.Errorf("set deadline: %w", err) + } + if _, err := io.ReadFull(s.conn, msgType[:]); err != nil { + return err + } + + var err error + switch msgType[0] { + case clientSetPixelFormat: + err = s.handleSetPixelFormat() + case clientSetEncodings: + err = s.handleSetEncodings() + case clientFramebufferUpdateRequest: + err = s.handleFBUpdateRequest() + case clientKeyEvent: + err = s.handleKeyEvent() + case clientPointerEvent: + err = s.handlePointerEvent() + case clientCutText: + err = s.handleCutText() + case clientNetbirdTypeText: + err = s.handleTypeText() + default: + return fmt.Errorf("unknown client message type: %d", msgType[0]) + } + // Clear the deadline only after the full message has been read and + // processed so payload reads in the handlers stay bounded. + _ = s.conn.SetDeadline(time.Time{}) + if err != nil { + return err + } + } +} + +func (s *session) handleSetPixelFormat() error { + var buf [19]byte // 3 padding + 16 pixel format + if _, err := io.ReadFull(s.conn, buf[:]); err != nil { + return fmt.Errorf("read SetPixelFormat: %w", err) + } + s.pf = parsePixelFormat(buf[3:19]) + return nil +} + +func (s *session) handleSetEncodings() error { + var header [3]byte // 1 padding + 2 number-of-encodings + if _, err := io.ReadFull(s.conn, header[:]); err != nil { + return fmt.Errorf("read SetEncodings header: %w", err) + } + numEnc := binary.BigEndian.Uint16(header[1:3]) + // RFB clients advertise a handful of real encodings plus pseudo-encodings. + // Cap to keep a malicious client from forcing a 256 KiB allocation per + // SetEncodings message. + const maxEncodings = 64 + if numEnc > maxEncodings { + return fmt.Errorf("SetEncodings: too many encodings (%d)", numEnc) + } + buf := make([]byte, int(numEnc)*4) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return err + } + + var encs []string + for i := range int(numEnc) { + enc := int32(binary.BigEndian.Uint32(buf[i*4 : i*4+4])) + switch enc { + case encZlib: + s.useZlib = true + if s.zlib == nil { + s.zlib = newZlibState() + } + encs = append(encs, "zlib") + case encHextile: + s.useHextile = true + encs = append(encs, "hextile") + case encTight: + s.useTight = true + if s.tight == nil { + s.tight = newTightState() + } + encs = append(encs, "tight") + } + } + if len(encs) > 0 { + s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) + } + return nil +} + +// handleFBUpdateRequest parses the request and hands it to the encoder +// goroutine. It never blocks on capture/encode, so the input dispatch loop +// stays responsive even when a previous frame is still being encoded. +func (s *session) handleFBUpdateRequest() error { + var req [9]byte + if _, err := io.ReadFull(s.conn, req[:]); err != nil { + return fmt.Errorf("read FBUpdateRequest: %w", err) + } + r := fbRequest{incremental: req[0] == 1} + // Channel is size 1. If a request is already pending, replace it with + // this fresher one so the encoder always works on the latest ask. + select { + case s.encodeCh <- r: + default: + select { + case <-s.encodeCh: + default: + } + select { + case s.encodeCh <- r: + default: + } + } + return nil +} + +// encoderLoop owns the capture → diff → encode → write pipeline. Running it +// off the read loop prevents a slow encode (zlib full-frame, many dirty +// tiles) from blocking inbound input events. +func (s *session) encoderLoop(done chan<- struct{}) { + defer close(done) + for req := range s.encodeCh { + if err := s.processFBRequest(req); err != nil { + s.log.Debugf("encode: %v", err) + // On write/capture error, close the connection so messageLoop + // exits and the session terminates cleanly. + s.conn.Close() + drainRequests(s.encodeCh) + return + } + } +} + +func (s *session) processFBRequest(req fbRequest) error { + img, err := s.captureFrame() + if errors.Is(err, errFrameUnchanged) { + // macOS hashes the raw capture bytes and short-circuits when the + // screen is byte-identical. Treat as "no dirty rects" to skip the + // diff and send an empty update. + s.idleFrames++ + delay := min(s.idleFrames*5, 100) + time.Sleep(time.Duration(delay) * time.Millisecond) + return s.sendEmptyUpdate() + } + if err != nil { + // Capture failures are transient on Windows: a Ctrl+Alt+Del or + // sign-out switches the OS to the secure desktop, and the DXGI + // duplicator on the previous desktop returns an error until the + // capturer reattaches on the new desktop. Don't tear down the + // session. Back off briefly and reply with an empty update so + // the client keeps re-requesting. + s.log.Debugf("capture (transient): %v", err) + time.Sleep(100 * time.Millisecond) + return s.sendEmptyUpdate() + } + + if req.incremental && s.prevFrame != nil { + rects := diffRects(s.prevFrame, img, s.serverW, s.serverH, tileSize) + if len(rects) == 0 { + // Nothing changed. Back off briefly before responding to reduce + // CPU usage when the screen is static. The client re-requests + // immediately after receiving our empty response, so without + // this delay we'd spin at ~1000fps checking for changes. + s.idleFrames++ + delay := min(s.idleFrames*5, 100) // 5ms → 100ms adaptive backoff + time.Sleep(time.Duration(delay) * time.Millisecond) + s.swapPrevCur() + return s.sendEmptyUpdate() + } + s.idleFrames = 0 + if s.shouldPromoteToFullFrame(rects) { + if err := s.sendFullUpdate(img); err != nil { + return err + } + s.swapPrevCur() + return nil + } + if err := s.sendDirtyRects(img, rects); err != nil { + return err + } + s.swapPrevCur() + return nil + } + + // Full update. + s.idleFrames = 0 + if err := s.sendFullUpdate(img); err != nil { + return err + } + s.swapPrevCur() + return nil +} + +// captureFrame returns a session-owned frame for this encode cycle. +// Capturers that implement captureIntoer (Linux X11, macOS) write directly +// into curFrame, saving a per-frame full-screen memcpy. Capturers that +// don't (Windows DXGI) return their own buffer which we copy into curFrame +// to keep the encoder's prevFrame stable across the next capture cycle. +func (s *session) captureFrame() (*image.RGBA, error) { + w, h := s.serverW, s.serverH + if s.curFrame == nil || s.curFrame.Rect.Dx() != w || s.curFrame.Rect.Dy() != h { + s.curFrame = image.NewRGBA(image.Rect(0, 0, w, h)) + } + + if ci, ok := s.capturer.(captureIntoer); ok { + if err := ci.CaptureInto(s.curFrame); err != nil { + return nil, err + } + return s.curFrame, nil + } + + src, err := s.capturer.Capture() + if err != nil { + return nil, err + } + if s.curFrame.Rect != src.Rect { + s.curFrame = image.NewRGBA(src.Rect) + } + copy(s.curFrame.Pix, src.Pix) + return s.curFrame, nil +} + +// shouldPromoteToFullFrame returns true when the dirty rect set covers a +// large enough fraction of the screen that a single full-frame zlib rect +// beats per-tile encoding on both CPU time and wire bytes. The crossover +// is measured via BenchmarkEncodeManyTilesVsFullFrame. +func (s *session) shouldPromoteToFullFrame(rects [][4]int) bool { + if s.serverW == 0 || s.serverH == 0 { + return false + } + var dirty int + for _, r := range rects { + dirty += r[2] * r[3] + } + return dirty*fullFramePromoteDen > s.serverW*s.serverH*fullFramePromoteNum +} + +// swapPrevCur makes the just-encoded frame the new prevFrame (for the next +// diff) and lets the old prevFrame buffer become the next curFrame. Avoids +// an 8 MB copy per frame compared to the old savePrevFrame path. +func (s *session) swapPrevCur() { + s.prevFrame, s.curFrame = s.curFrame, s.prevFrame +} + +// sendEmptyUpdate sends a FramebufferUpdate with zero rectangles. +func (s *session) sendEmptyUpdate() error { + var buf [4]byte + buf[0] = serverFramebufferUpdate + s.writeMu.Lock() + _, err := s.conn.Write(buf[:]) + s.writeMu.Unlock() + return err +} + +func (s *session) sendFullUpdate(img *image.RGBA) error { + w, h := s.serverW, s.serverH + + var buf []byte + if s.useZlib && s.zlib != nil { + buf = encodeZlibRect(img, s.pf, 0, 0, w, h, s.zlib) + } else { + buf = encodeRawRect(img, s.pf, 0, 0, w, h) + } + + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err +} + +func (s *session) sendDirtyRects(img *image.RGBA, rects [][4]int) error { + // Build a multi-rectangle FramebufferUpdate. + // Header: type(1) + padding(1) + numRects(2) + header := make([]byte, 4) + header[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(header[2:4], uint16(len(rects))) + + s.writeMu.Lock() + defer s.writeMu.Unlock() + + if _, err := s.conn.Write(header); err != nil { + return err + } + + for _, r := range rects { + x, y, w, h := r[0], r[1], r[2], r[3] + rectBuf := s.encodeTile(img, x, y, w, h) + if _, err := s.conn.Write(rectBuf); err != nil { + return err + } + } + return nil +} + +// encodeTile produces the on-wire rect bytes for a single dirty tile, +// picking the cheapest encoding available: +// - Hextile SolidFill when the tile is a single colour (~20 bytes for a +// 64×64 tile instead of ~1-2 KB zlib, ~16 KB raw). +// - Zlib when the client negotiated it. +// - Raw otherwise. +// +// Output omits the 4-byte FramebufferUpdate header; callers combine multiple +// tiles into one message. +func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { + if s.useHextile { + if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform { + r := byte(pixel) + g := byte(pixel >> 8) + b := byte(pixel >> 16) + return encodeHextileSolidRect(r, g, b, s.pf, rect{x, y, w, h}) + } + // Full Hextile encoder disabled pending investigation of 16×16 + // red-tile artifacts on Windows. Solid-fill fast path is safe. + } + // Larger merged rects: prefer Tight (JPEG for photo-like, Basic+zlib + // otherwise) when the client supports it AND the negotiated format is + // compatible with Tight's mandatory 24-bit RGB TPIXEL encoding. Tight is + // dramatically better than RFB Zlib on photographic content and + // competitive on UI. + if s.useTight && s.tight != nil && pfIsTightCompatible(s.pf) { + return encodeTightRect(img, s.pf, x, y, w, h, s.tight) + } + if s.useZlib && s.zlib != nil { + return encodeZlibRect(img, s.pf, x, y, w, h, s.zlib)[4:] + } + return encodeRawRect(img, s.pf, x, y, w, h)[4:] +} + +func (s *session) handleKeyEvent() error { + var data [7]byte + if _, err := io.ReadFull(s.conn, data[:]); err != nil { + return fmt.Errorf("read KeyEvent: %w", err) + } + down := data[0] == 1 + keysym := binary.BigEndian.Uint32(data[3:7]) + s.injector.InjectKey(keysym, down) + return nil +} + +func (s *session) handlePointerEvent() error { + var data [5]byte + if _, err := io.ReadFull(s.conn, data[:]); err != nil { + return fmt.Errorf("read PointerEvent: %w", err) + } + buttonMask := data[0] + x := int(binary.BigEndian.Uint16(data[1:3])) + y := int(binary.BigEndian.Uint16(data[3:5])) + s.injector.InjectPointer(buttonMask, x, y, s.serverW, s.serverH) + return nil +} + +func (s *session) handleCutText() error { + var header [7]byte // 3 padding + 4 length + if _, err := io.ReadFull(s.conn, header[:]); err != nil { + return fmt.Errorf("read CutText header: %w", err) + } + length := binary.BigEndian.Uint32(header[3:7]) + if length > maxCutTextBytes { + return fmt.Errorf("cut text too large: %d bytes", length) + } + buf := make([]byte, length) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return fmt.Errorf("read CutText payload: %w", err) + } + s.injector.SetClipboard(string(buf)) + return nil +} + +// handleTypeText handles the NetBird-specific PasteAndType message used by +// the dashboard's Paste button. Wire format mirrors CutText: 3-byte +// padding + 4-byte length + text bytes. +func (s *session) handleTypeText() error { + var header [7]byte + if _, err := io.ReadFull(s.conn, header[:]); err != nil { + return fmt.Errorf("read TypeText header: %w", err) + } + length := binary.BigEndian.Uint32(header[3:7]) + if length > maxCutTextBytes { + return fmt.Errorf("type text too large: %d bytes", length) + } + buf := make([]byte, length) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return fmt.Errorf("read TypeText payload: %w", err) + } + s.injector.TypeText(string(buf)) + return nil +} + +// sendServerCutText sends clipboard text from the server to the client. +func (s *session) sendServerCutText(text string) error { + data := []byte(text) + buf := make([]byte, 8+len(data)) + buf[0] = serverCutText + // buf[1:4] = padding (zero) + binary.BigEndian.PutUint32(buf[4:8], uint32(len(data))) + copy(buf[8:], data) + + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err +} + +// drainRequests consumes any pending requests so the sender's close completes +// cleanly after the encoder loop has decided to exit on error. Returns the +// number of drained requests to defeat empty-block lints; callers ignore it. +func drainRequests(ch chan fbRequest) int { + var drained int + for range ch { + drained++ + } + return drained +} + +// pfIsTightCompatible reports whether the negotiated client pixel format +// matches Tight's TPIXEL constraint: 32 bpp true colour with 8-bit RGB +// channels at standard shifts (R=16, G=8, B=0). For anything else we fall +// back to Zlib/Hextile/Raw which respect pf in full. +func pfIsTightCompatible(pf clientPixelFormat) bool { + return pf.bpp == 32 && + pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 && + pf.rShift == 16 && pf.gShift == 8 && pf.bShift == 0 +} diff --git a/client/vnc/server/shutdown_state.go b/client/vnc/server/shutdown_state.go new file mode 100644 index 00000000000..9f3154e712d --- /dev/null +++ b/client/vnc/server/shutdown_state.go @@ -0,0 +1,80 @@ +//go:build unix + +package server + +import ( + "fmt" + "os" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" +) + +// ShutdownState tracks VNC virtual session processes for crash recovery. +// Persisted by the state manager; on restart, residual processes are killed. +type ShutdownState struct { + // Processes maps a description to its PID (e.g., "xvfb:50" -> 1234). + Processes map[string]int `json:"processes,omitempty"` +} + +// Name returns the state name for the state manager. +func (s *ShutdownState) Name() string { + return "vnc_sessions_state" +} + +// Cleanup kills any residual VNC session processes left from a crash. +func (s *ShutdownState) Cleanup() error { + if len(s.Processes) == 0 { + return nil + } + + for desc, pid := range s.Processes { + if pid <= 0 { + continue + } + if !isOurProcess(pid, desc) { + log.Debugf("cleanup:skipping PID %d (%s), not ours", pid, desc) + continue + } + log.Infof("cleanup:killing residual process %d (%s)", pid, desc) + // Kill the process group (negative PID) to get children too. + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { + // Try individual process if group kill fails. + if killErr := syscall.Kill(pid, syscall.SIGKILL); killErr != nil { + log.Debugf("cleanup: kill pid %d (%s): group kill: %v, single kill: %v", pid, desc, err, killErr) + } + } + } + + s.Processes = nil + return nil +} + +// isOurProcess verifies the PID still belongs to a VNC-related process +// by checking /proc//cmdline (Linux) or the process name. +func isOurProcess(pid int, desc string) bool { + // Check if the process exists at all. + if err := syscall.Kill(pid, 0); err != nil { + return false + } + + // On Linux, verify via /proc cmdline. + cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if err != nil { + log.Debugf("cleanup: cannot read /proc/%d/cmdline: %v, treating PID as foreign", pid, err) + return false + } + + cmd := string(cmdline) + // Match against expected process types. + if strings.Contains(desc, "xvfb") || strings.Contains(desc, "xorg") { + return strings.Contains(cmd, "Xvfb") || strings.Contains(cmd, "Xorg") + } + if strings.Contains(desc, "desktop") { + return strings.Contains(cmd, "session") || strings.Contains(cmd, "plasma") || + strings.Contains(cmd, "gnome") || strings.Contains(cmd, "xfce") || + strings.Contains(cmd, "dbus-launch") + } + return false +} diff --git a/client/vnc/server/stubs.go b/client/vnc/server/stubs.go new file mode 100644 index 00000000000..0ac44b50694 --- /dev/null +++ b/client/vnc/server/stubs.go @@ -0,0 +1,46 @@ +package server + +import ( + "fmt" + "image" +) + +// StubCapturer is a placeholder for platforms without screen capture support. +type StubCapturer struct{} + +// Width returns 0 on unsupported platforms. +func (c *StubCapturer) Width() int { return 0 } + +// Height returns 0 on unsupported platforms. +func (c *StubCapturer) Height() int { return 0 } + +// Capture returns an error on unsupported platforms. +func (c *StubCapturer) Capture() (*image.RGBA, error) { + return nil, fmt.Errorf("screen capture not supported on this platform") +} + +// StubInputInjector is a placeholder for platforms without input injection support. +type StubInputInjector struct{} + +// InjectKey is a no-op on unsupported platforms. +func (s *StubInputInjector) InjectKey(_ uint32, _ bool) { + // no-op +} + +// InjectPointer is a no-op on unsupported platforms. +func (s *StubInputInjector) InjectPointer(_ uint8, _, _, _, _ int) { + // no-op +} + +// SetClipboard is a no-op on unsupported platforms. +func (s *StubInputInjector) SetClipboard(_ string) { + // no-op +} + +// GetClipboard returns empty on unsupported platforms. +func (s *StubInputInjector) GetClipboard() string { return "" } + +// TypeText is a no-op on unsupported platforms. +func (s *StubInputInjector) TypeText(_ string) { + // no-op +} diff --git a/client/vnc/server/swizzle.go b/client/vnc/server/swizzle.go new file mode 100644 index 00000000000..4b34ed63e21 --- /dev/null +++ b/client/vnc/server/swizzle.go @@ -0,0 +1,29 @@ +package server + +import "unsafe" + +// swizzleBGRAtoRGBA swaps B and R channels in a BGRA pixel buffer and copies +// into dst in-place (dst and src may alias). Operates on uint32 words: one +// read-modify-write per pixel, which is meaningfully faster than the naive +// three-byte-store per pixel for large buffers like framebuffers. +// +// The alpha byte is forced to 0xff so callers that capture from X11 GetImage +// (where the X server leaves the pad byte as zero) still get an opaque image. +func swizzleBGRAtoRGBA(dst, src []byte) { + n := len(dst) / 4 + if len(src)/4 < n { + n = len(src) / 4 + } + if n == 0 { + return + } + dp := unsafe.Slice((*uint32)(unsafe.Pointer(&dst[0])), n) + sp := unsafe.Slice((*uint32)(unsafe.Pointer(&src[0])), n) + for i := range n { + p := sp[i] + // p in memory: B, G, R, A -> as uint32 little-endian: 0xAARRGGBB + // Want memory: R, G, B, 0xFF -> uint32 little-endian: 0xFFBBGGRR + dp[i] = 0xFF000000 | (p & 0x0000FF00) | ((p & 0x00FF0000) >> 16) | ((p & 0x000000FF) << 16) + } +} + diff --git a/client/vnc/server/tight_test.go b/client/vnc/server/tight_test.go new file mode 100644 index 00000000000..0e0aaab4db6 --- /dev/null +++ b/client/vnc/server/tight_test.go @@ -0,0 +1,84 @@ +package server + +import ( + "bytes" + "image/jpeg" + "testing" +) + +func decodeTightLength(buf []byte) (n, consumed int) { + b0 := buf[0] + n = int(b0 & 0x7f) + if b0&0x80 == 0 { + return n, 1 + } + b1 := buf[1] + n |= int(b1&0x7f) << 7 + if b1&0x80 == 0 { + return n, 2 + } + b2 := buf[2] + n |= int(b2) << 14 + return n, 3 +} + +func TestEncodeTightFill(t *testing.T) { + pf := defaultClientPixelFormat() + img := makeUniformImage(64, 64, 0x12, 0x34, 0x56) + tstate := newTightState() + buf := encodeTightRect(img, pf, 0, 0, 64, 64, tstate) + if len(buf) != 12+1+3 { + t.Fatalf("fill rect should be 16 bytes, got %d", len(buf)) + } + if buf[12] != tightFillSubenc { + t.Fatalf("expected fill subenc, got 0x%02x", buf[12]) + } + if buf[13] != 0x12 || buf[14] != 0x34 || buf[15] != 0x56 { + t.Fatalf("wrong fill colour: %v", buf[13:16]) + } +} + +func TestEncodeTightBasic(t *testing.T) { + pf := defaultClientPixelFormat() + img := makeTwoColorImage(64, 64) + tstate := newTightState() + buf := encodeTightRect(img, pf, 0, 0, 64, 64, tstate) + if buf[12]&0xf0 != tightBasicFilter { + t.Fatalf("expected basic+filter subenc, got 0x%02x", buf[12]) + } + if buf[13] != tightFilterCopy { + t.Fatalf("expected copy filter, got 0x%02x", buf[13]) + } + // Length prefix and zlib stream follow. + n, _ := decodeTightLength(buf[14:]) + if n == 0 { + t.Fatalf("zero-length basic stream") + } +} + +func TestEncodeTightJPEG(t *testing.T) { + pf := defaultClientPixelFormat() + img := makeBenchImage(128, 128, 7) // random → many colours + tstate := newTightState() + buf := encodeTightRect(img, pf, 0, 0, 128, 128, tstate) + if buf[12] != tightJPEGSubenc { + t.Fatalf("expected JPEG subenc, got 0x%02x", buf[12]) + } + n, consumed := decodeTightLength(buf[13:]) + jpegBytes := buf[13+consumed : 13+consumed+n] + if _, err := jpeg.Decode(bytes.NewReader(jpegBytes)); err != nil { + t.Fatalf("emitted JPEG bytes do not decode: %v", err) + } +} + +func TestSampledColorCount(t *testing.T) { + uniform := makeUniformImage(64, 64, 0x10, 0x20, 0x30) + if c := sampledColorCountInto(map[uint32]struct{}{},uniform, 0, 0, 64, 64, 32); c != 1 { + t.Fatalf("uniform should be 1 colour, got %d", c) + } + rnd := makeBenchImage(128, 128, 1) + if c := sampledColorCountInto(map[uint32]struct{}{},rnd, 0, 0, 128, 128, 16); c <= 16 { + t.Fatalf("random image should exceed colour cap, got %d", c) + } +} + diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go new file mode 100644 index 00000000000..cf12f1654f8 --- /dev/null +++ b/client/vnc/server/virtual_x11.go @@ -0,0 +1,725 @@ +//go:build (linux && !android) || freebsd + +package server + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +// VirtualSession manages a virtual X11 display (Xvfb) with a desktop session +// running as a target user. It implements ScreenCapturer and InputInjector by +// delegating to an X11Capturer/X11InputInjector pointed at the virtual display. +const ( + sessionIdleTimeout = 5 * time.Minute + + defaultSessionWidth uint16 = 1280 + defaultSessionHeight uint16 = 800 +) + +type VirtualSession struct { + mu sync.Mutex + display string + user *user.User + uid uint32 + gid uint32 + groups []uint32 + width uint16 + height uint16 + xvfb *exec.Cmd + desktop *exec.Cmd + poller *X11Poller + injector *X11InputInjector + log *log.Entry + stopped bool + clients int + idleTimer *time.Timer + onIdle func() // called when idle timeout fires or Xvfb dies +} + +// StartVirtualSession creates and starts a virtual X11 session for the given +// user. Requires root privileges to create sessions as other users. width and +// height request the virtual display geometry; 0 values fall back to the +// defaults. +func StartVirtualSession(username string, width, height uint16, logger *log.Entry) (*VirtualSession, error) { + if os.Getuid() != 0 { + return nil, fmt.Errorf("virtual sessions require root privileges") + } + + if _, err := exec.LookPath("Xvfb"); err != nil { + if _, err := exec.LookPath("Xorg"); err != nil { + return nil, fmt.Errorf("neither Xvfb nor Xorg found (install xvfb or xserver-xorg)") + } + if !hasDummyDriver() { + return nil, fmt.Errorf("xvfb not found and xorg dummy driver not installed (install xvfb or xf86-video-dummy)") + } + } + + u, err := user.Lookup(username) + if err != nil { + return nil, fmt.Errorf("lookup user %s: %w", username, err) + } + + uid, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse uid: %w", err) + } + gid, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse gid: %w", err) + } + + groups, err := supplementaryGroups(u) + if err != nil { + logger.Debugf("supplementary groups for %s: %v", username, err) + } + + if width == 0 { + width = defaultSessionWidth + } + if height == 0 { + height = defaultSessionHeight + } + + vs := &VirtualSession{ + user: u, + uid: uint32(uid), + gid: uint32(gid), + groups: groups, + width: width, + height: height, + log: logger.WithField("vnc_user", username), + } + + if err := vs.start(); err != nil { + return nil, err + } + return vs, nil +} + +func (vs *VirtualSession) start() error { + display, err := findFreeDisplay() + if err != nil { + return fmt.Errorf("find free display: %w", err) + } + vs.display = display + + if err := vs.startXvfb(); err != nil { + return err + } + + socketPath := fmt.Sprintf("/tmp/.X11-unix/X%s", vs.display[1:]) + if err := waitForPath(socketPath, 5*time.Second); err != nil { + vs.stopXvfb() + return fmt.Errorf("wait for X11 socket %s: %w", socketPath, err) + } + + // Grant the target user access to the display via xhost. + xhostCmd := exec.Command("xhost", "+SI:localuser:"+vs.user.Username) + xhostCmd.Env = []string{"DISPLAY=" + vs.display} + if out, err := xhostCmd.CombinedOutput(); err != nil { + vs.log.Debugf("xhost: %s (%v)", strings.TrimSpace(string(out)), err) + } + + vs.poller = NewX11Poller(vs.display) + + injector, err := NewX11InputInjector(vs.display) + if err != nil { + vs.stopXvfb() + return fmt.Errorf("create X11 injector for %s: %w", vs.display, err) + } + vs.injector = injector + + if err := vs.startDesktop(); err != nil { + vs.injector.Close() + vs.stopXvfb() + return fmt.Errorf("start desktop: %w", err) + } + + vs.log.Infof("virtual session started: display=%s user=%s", vs.display, vs.user.Username) + return nil +} + +// ClientConnect increments the client count and cancels any idle timer. +func (vs *VirtualSession) ClientConnect() { + vs.mu.Lock() + defer vs.mu.Unlock() + vs.clients++ + if vs.idleTimer != nil { + vs.idleTimer.Stop() + vs.idleTimer = nil + } +} + +// ClientDisconnect decrements the client count. When the last client +// disconnects, starts an idle timer that destroys the session. +func (vs *VirtualSession) ClientDisconnect() { + vs.mu.Lock() + defer vs.mu.Unlock() + vs.clients-- + if vs.clients <= 0 { + vs.clients = 0 + vs.log.Infof("no VNC clients connected, session will be destroyed in %s", sessionIdleTimeout) + vs.idleTimer = time.AfterFunc(sessionIdleTimeout, vs.idleExpired) + } +} + +// idleExpired is called by the idle timer. It stops the session and +// notifies the session manager via onIdle so it removes us from the map. +func (vs *VirtualSession) idleExpired() { + vs.log.Info("idle timeout reached, destroying virtual session") + vs.Stop() + // onIdle acquires sessionManager.mu; safe because Stop() has released vs.mu. + if vs.onIdle != nil { + vs.onIdle() + } +} + +// isAlive returns true if the session is running and its X server socket exists. +func (vs *VirtualSession) isAlive() bool { + vs.mu.Lock() + stopped := vs.stopped + display := vs.display + vs.mu.Unlock() + + if stopped { + return false + } + // Verify the X socket still exists on disk. + socketPath := fmt.Sprintf("/tmp/.X11-unix/X%s", display[1:]) + if _, err := os.Stat(socketPath); err != nil { + return false + } + return true +} + +// Capturer returns the screen capturer for this virtual session. +func (vs *VirtualSession) Capturer() ScreenCapturer { + return vs.poller +} + +// Injector returns the input injector for this virtual session. +func (vs *VirtualSession) Injector() InputInjector { + return vs.injector +} + +// Display returns the X11 display string (e.g., ":99"). +func (vs *VirtualSession) Display() string { + return vs.display +} + +// Stop terminates the virtual session, killing the desktop and Xvfb. +func (vs *VirtualSession) Stop() { + vs.mu.Lock() + defer vs.mu.Unlock() + + if vs.stopped { + return + } + vs.stopped = true + + if vs.injector != nil { + vs.injector.Close() + } + + vs.stopDesktop() + vs.stopXvfb() + + vs.log.Info("virtual session stopped") +} + +func (vs *VirtualSession) startXvfb() error { + if _, err := exec.LookPath("Xvfb"); err == nil { + return vs.startXvfbDirect() + } + return vs.startXorgDummy() +} + +func (vs *VirtualSession) startXvfbDirect() error { + geom := fmt.Sprintf("%dx%dx24", vs.width, vs.height) + vs.xvfb = exec.Command("Xvfb", vs.display, + "-screen", "0", geom, + "-ac", + "-nolisten", "tcp", + ) + vs.xvfb.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGTERM} + + if err := vs.xvfb.Start(); err != nil { + return fmt.Errorf("start Xvfb on %s: %w", vs.display, err) + } + vs.log.Infof("Xvfb started on %s (pid=%d)", vs.display, vs.xvfb.Process.Pid) + + go vs.monitorXvfb() + + return nil +} + +// startXorgDummy starts Xorg with the dummy video driver as a fallback when +// Xvfb is not installed. Most systems with a desktop have Xorg available. +func (vs *VirtualSession) startXorgDummy() error { + conf := fmt.Sprintf(`Section "Device" + Identifier "dummy" + Driver "dummy" + VideoRam 256000 +EndSection +Section "Screen" + Identifier "screen" + Device "dummy" + DefaultDepth 24 + SubSection "Display" + Depth 24 + Modes "%dx%d" + EndSubSection +EndSection +`, vs.width, vs.height) + f, err := os.CreateTemp("", fmt.Sprintf("nbvnc-dummy-%s-*.conf", vs.display[1:])) + if err != nil { + return fmt.Errorf("create Xorg dummy config: %w", err) + } + confPath := f.Name() + if _, err := f.WriteString(conf); err != nil { + f.Close() + os.Remove(confPath) + return fmt.Errorf("write Xorg dummy config: %w", err) + } + if err := f.Chmod(0600); err != nil { + f.Close() + os.Remove(confPath) + return fmt.Errorf("chmod Xorg dummy config: %w", err) + } + if err := f.Close(); err != nil { + os.Remove(confPath) + return fmt.Errorf("close Xorg dummy config: %w", err) + } + + vs.xvfb = exec.Command("Xorg", vs.display, + "-config", confPath, + "-noreset", + "-nolisten", "tcp", + "-ac", + ) + vs.xvfb.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGTERM} + + if err := vs.xvfb.Start(); err != nil { + os.Remove(confPath) + return fmt.Errorf("start Xorg dummy on %s: %w", vs.display, err) + } + vs.log.Infof("Xorg (dummy driver) started on %s (pid=%d)", vs.display, vs.xvfb.Process.Pid) + + go func() { + vs.monitorXvfb() + os.Remove(confPath) + }() + + return nil +} + +// monitorXvfb waits for the Xvfb/Xorg process to exit. If it exits +// unexpectedly (not via Stop), the session is marked as dead and the +// onIdle callback fires so the session manager removes it from the map. +// The next GetOrCreate call for this user will create a fresh session. +func (vs *VirtualSession) monitorXvfb() { + if err := vs.xvfb.Wait(); err != nil { + vs.log.Debugf("X server exited: %v", err) + } + + vs.mu.Lock() + alreadyStopped := vs.stopped + if !alreadyStopped { + vs.log.Warn("X server exited unexpectedly, marking session as dead") + vs.stopped = true + if vs.idleTimer != nil { + vs.idleTimer.Stop() + vs.idleTimer = nil + } + if vs.injector != nil { + vs.injector.Close() + } + vs.stopDesktop() + } + onIdle := vs.onIdle + vs.mu.Unlock() + + if !alreadyStopped && onIdle != nil { + onIdle() + } +} + +func (vs *VirtualSession) stopXvfb() { + if vs.xvfb == nil || vs.xvfb.Process == nil { + return + } + if err := syscall.Kill(-vs.xvfb.Process.Pid, syscall.SIGTERM); err != nil { + vs.log.Debugf("SIGTERM xvfb group: %v", err) + } + time.Sleep(200 * time.Millisecond) + if err := syscall.Kill(-vs.xvfb.Process.Pid, syscall.SIGKILL); err != nil { + vs.log.Debugf("SIGKILL xvfb group: %v", err) + } +} + +func (vs *VirtualSession) startDesktop() error { + session := detectDesktopSession() + + // Wrap the desktop command with dbus-launch to provide a session bus. + // Without this, most desktop environments (XFCE, MATE, etc.) fail immediately. + var args []string + if _, err := exec.LookPath("dbus-launch"); err == nil { + args = append([]string{"dbus-launch", "--exit-with-session"}, session...) + } else { + args = session + } + + vs.desktop = exec.Command(args[0], args[1:]...) + vs.desktop.Dir = vs.user.HomeDir + vs.desktop.Env = vs.buildUserEnv() + vs.desktop.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: vs.uid, + Gid: vs.gid, + Groups: vs.groups, + }, + Setsid: true, + Pdeathsig: syscall.SIGTERM, + } + + if err := vs.desktop.Start(); err != nil { + return fmt.Errorf("start desktop session (%v): %w", args, err) + } + vs.log.Infof("desktop session started: %v (pid=%d)", args, vs.desktop.Process.Pid) + + go vs.monitorDesktop() + + return nil +} + +// monitorDesktop waits for the desktop-session process to exit. When the user +// logs out of GNOME/KDE/XFCE/etc., the session process terminates while Xvfb +// keeps running, leaving a blank root window. Tear the whole virtual session +// down so the next connect starts fresh with a login. +func (vs *VirtualSession) monitorDesktop() { + if err := vs.desktop.Wait(); err != nil { + vs.log.Debugf("desktop session exited: %v", err) + } + + vs.mu.Lock() + alreadyStopped := vs.stopped + if !alreadyStopped { + vs.log.Info("desktop session exited (logout), tearing down virtual session") + vs.stopped = true + if vs.idleTimer != nil { + vs.idleTimer.Stop() + vs.idleTimer = nil + } + if vs.injector != nil { + vs.injector.Close() + } + vs.stopXvfb() + } + onIdle := vs.onIdle + vs.mu.Unlock() + + if !alreadyStopped && onIdle != nil { + onIdle() + } +} + +func (vs *VirtualSession) stopDesktop() { + if vs.desktop == nil || vs.desktop.Process == nil { + return + } + if err := syscall.Kill(-vs.desktop.Process.Pid, syscall.SIGTERM); err != nil { + vs.log.Debugf("SIGTERM desktop group: %v", err) + } + time.Sleep(200 * time.Millisecond) + if err := syscall.Kill(-vs.desktop.Process.Pid, syscall.SIGKILL); err != nil { + vs.log.Debugf("SIGKILL desktop group: %v", err) + } +} + +func (vs *VirtualSession) buildUserEnv() []string { + return []string{ + "DISPLAY=" + vs.display, + "HOME=" + vs.user.HomeDir, + "USER=" + vs.user.Username, + "LOGNAME=" + vs.user.Username, + "SHELL=" + getUserShell(vs.user.Uid), + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "XDG_RUNTIME_DIR=/run/user/" + vs.user.Uid, + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + vs.user.Uid + "/bus", + } +} + +// detectDesktopSession discovers available desktop sessions from the standard +// /usr/share/xsessions/*.desktop files (FreeDesktop standard, used by all +// display managers). Falls back to a hardcoded list if no .desktop files found. +func detectDesktopSession() []string { + // Scan xsessions directories (Linux: /usr/share, FreeBSD: /usr/local/share). + for _, dir := range []string{"/usr/share/xsessions", "/usr/local/share/xsessions"} { + if cmd := findXSession(dir); cmd != nil { + return cmd + } + } + + // Fallback: try common session commands directly. + fallbacks := [][]string{ + {"startplasma-x11"}, + {"gnome-session"}, + {"xfce4-session"}, + {"mate-session"}, + {"cinnamon-session"}, + {"openbox-session"}, + {"xterm"}, + } + for _, s := range fallbacks { + if _, err := exec.LookPath(s[0]); err == nil { + return s + } + } + return []string{"xterm"} +} + +// sessionPriority defines preference order for desktop environments. +// Lower number = higher priority. Unknown sessions get 100. +var sessionPriority = map[string]int{ + "plasma": 1, // KDE + "gnome": 2, + "xfce": 3, + "mate": 4, + "cinnamon": 5, + "lxqt": 6, + "lxde": 7, + "budgie": 8, + "openbox": 20, + "fluxbox": 21, + "i3": 22, + "xinit": 50, // generic user session + "lightdm": 50, + "default": 50, +} + +func findXSession(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + candidates := collectSessionCandidates(dir, entries) + if len(candidates) == 0 { + return nil + } + best := bestSessionCandidate(candidates) + parts := strings.Fields(best.cmd) + if _, err := exec.LookPath(parts[0]); err != nil { + return nil + } + return parts +} + +type sessionCandidate struct { + cmd string + priority int +} + +func collectSessionCandidates(dir string, entries []os.DirEntry) []sessionCandidate { + var out []sessionCandidate + for _, e := range entries { + c, ok := parseSessionEntry(dir, e) + if ok { + out = append(out, c) + } + } + return out +} + +// parseSessionEntry reads a single .desktop file and extracts its Exec +// command plus the priority hint to be used when picking the best session. +func parseSessionEntry(dir string, e os.DirEntry) (sessionCandidate, bool) { + if !strings.HasSuffix(e.Name(), ".desktop") { + return sessionCandidate{}, false + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return sessionCandidate{}, false + } + execCmd := extractExecLine(data) + if execCmd == "" || execCmd == "default" { + return sessionCandidate{}, false + } + return sessionCandidate{cmd: execCmd, priority: sessionPriorityFor(e.Name(), execCmd)}, true +} + +func extractExecLine(data []byte) string { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "Exec=") { + return strings.TrimSpace(strings.TrimPrefix(line, "Exec=")) + } + } + return "" +} + +func sessionPriorityFor(name, execCmd string) int { + pri := 100 + lower := strings.ToLower(name + " " + execCmd) + for keyword, p := range sessionPriority { + if strings.Contains(lower, keyword) && p < pri { + pri = p + } + } + return pri +} + +func bestSessionCandidate(candidates []sessionCandidate) sessionCandidate { + best := candidates[0] + for _, c := range candidates[1:] { + if c.priority < best.priority { + best = c + } + } + return best +} + +// findFreeDisplay scans for an unused X11 display number. +func findFreeDisplay() (string, error) { + for n := 50; n < 200; n++ { + lockFile := fmt.Sprintf("/tmp/.X%d-lock", n) + socketFile := fmt.Sprintf("/tmp/.X11-unix/X%d", n) + if _, err := os.Stat(lockFile); err == nil { + continue + } + if _, err := os.Stat(socketFile); err == nil { + continue + } + return fmt.Sprintf(":%d", n), nil + } + return "", fmt.Errorf("no free X11 display found (checked :50-:199)") +} + +// waitForPath polls until a filesystem path exists or the timeout expires. +func waitForPath(path string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for %s", path) +} + +// getUserShell returns the login shell for the given UID. +func getUserShell(uid string) string { + data, err := os.ReadFile("/etc/passwd") + if err != nil { + return "/bin/sh" + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Split(line, ":") + if len(fields) >= 7 && fields[2] == uid { + return fields[6] + } + } + return "/bin/sh" +} + +// supplementaryGroups returns the supplementary group IDs for a user. +func supplementaryGroups(u *user.User) ([]uint32, error) { + gids, err := u.GroupIds() + if err != nil { + return nil, err + } + var groups []uint32 + for _, g := range gids { + id, err := strconv.ParseUint(g, 10, 32) + if err != nil { + continue + } + groups = append(groups, uint32(id)) + } + return groups, nil +} + +// sessionManager tracks active virtual sessions by username. +type sessionManager struct { + mu sync.Mutex + sessions map[string]*VirtualSession + log *log.Entry +} + +func newSessionManager(logger *log.Entry) *sessionManager { + return &sessionManager{ + sessions: make(map[string]*VirtualSession), + log: logger, + } +} + +// GetOrCreate returns an existing virtual session or creates a new one with +// the requested geometry. If a previous session for this user is alive it is +// reused regardless of the requested geometry; the first caller's size wins +// until the session idles out. If a previous session is stopped or its X +// server died, it is replaced. +func (sm *sessionManager) GetOrCreate(username string, width, height uint16) (vncSession, error) { + sm.mu.Lock() + defer sm.mu.Unlock() + + if vs, ok := sm.sessions[username]; ok { + if vs.isAlive() { + return vs, nil + } + sm.log.Infof("replacing dead virtual session for %s", username) + vs.Stop() + delete(sm.sessions, username) + } + + vs, err := StartVirtualSession(username, width, height, sm.log) + if err != nil { + return nil, err + } + vs.onIdle = func() { + sm.mu.Lock() + defer sm.mu.Unlock() + if cur, ok := sm.sessions[username]; ok && cur == vs { + delete(sm.sessions, username) + sm.log.Infof("removed idle virtual session for %s", username) + } + } + sm.sessions[username] = vs + return vs, nil +} + +// hasDummyDriver checks common paths for the Xorg dummy video driver. +func hasDummyDriver() bool { + paths := []string{ + "/usr/lib/xorg/modules/drivers/dummy_drv.so", // Debian/Ubuntu + "/usr/lib64/xorg/modules/drivers/dummy_drv.so", // RHEL/Fedora + "/usr/local/lib/xorg/modules/drivers/dummy_drv.so", // FreeBSD + "/usr/lib/x86_64-linux-gnu/xorg/modules/drivers/dummy_drv.so", // Debian multiarch + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +// StopAll terminates all active virtual sessions. +func (sm *sessionManager) StopAll() { + sm.mu.Lock() + defer sm.mu.Unlock() + + for username, vs := range sm.sessions { + vs.Stop() + delete(sm.sessions, username) + sm.log.Infof("stopped virtual session for %s", username) + } +} diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 066fe043bf7..9bd5fbb7dd3 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -19,8 +19,8 @@ import ( nbstatus "github.com/netbirdio/netbird/client/status" wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture" "github.com/netbirdio/netbird/client/wasm/internal/http" - "github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/ssh" + "github.com/netbirdio/netbird/client/wasm/internal/vnc" "github.com/netbirdio/netbird/util" ) @@ -364,27 +364,131 @@ func createProxyRequestMethod(client *netbird.Client) js.Func { }) } -// createRDPProxyMethod creates the RDP proxy method -func createRDPProxyMethod(client *netbird.Client) js.Func { +// createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging. +// JS signature: createVNCProxy(hostname, port, mode?, username?, jwt?, sessionID?, width?, height?) +// mode: "attach" (default) or "session" +// username: required when mode is "session" +// jwt: authentication token (from OIDC session) +// sessionID: Windows session ID (0 = console/auto) +// width/height: requested viewport size for session mode (0 = server default) +func createVNCProxyMethod(client *netbird.Client) js.Func { return js.FuncOf(func(_ js.Value, args []js.Value) any { - if len(args) < 2 { - return js.ValueOf("error: hostname and port required") + params, err := parseVNCProxyArgs(args) + if err != nil { + if params.rejectViaPromise { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf(err.Error())) + }) + } + return js.ValueOf(err.Error()) } + proxy := vnc.NewVNCProxy(client) + return proxy.CreateProxy(vnc.ProxyRequest{ + Hostname: params.hostname, + Port: params.port, + Mode: params.mode, + Username: params.username, + JWT: params.jwt, + SessionID: params.sessionID, + Width: params.width, + Height: params.height, + }) + }) +} - if args[0].Type() != js.TypeString { - return createPromise(func(resolve, reject js.Value) { - reject.Invoke(js.ValueOf("hostname parameter must be a string")) - }) +type vncProxyParams struct { + hostname string + port string + mode string + username string + jwt string + sessionID uint32 + width uint16 + height uint16 + rejectViaPromise bool // true when the JS caller expects a rejected Promise instead of a plain string return +} + +// parseVNCProxyArgs validates JS args for createVNCProxyMethod and returns +// the parsed params plus the first validation error (nil on success). +// vncProxyParams.rejectViaPromise tells the caller which JS-side response +// path to use for the returned error. +func parseVNCProxyArgs(args []js.Value) (vncProxyParams, error) { + var p vncProxyParams + if err := parseVNCProxyRequiredArgs(args, &p); err != nil { + return p, err + } + if err := parseVNCProxyOptionalStrings(args, &p); err != nil { + return p, err + } + if err := parseVNCProxyOptionalNumbers(args, &p); err != nil { + return p, err + } + return p, nil +} + +func parseVNCProxyRequiredArgs(args []js.Value, p *vncProxyParams) error { + if len(args) < 2 { + return fmt.Errorf("hostname and port required") + } + if args[0].Type() != js.TypeString { + p.rejectViaPromise = true + return fmt.Errorf("hostname parameter must be a string") + } + if args[1].Type() != js.TypeString { + p.rejectViaPromise = true + return fmt.Errorf("port parameter must be a string") + } + p.hostname = args[0].String() + p.port = args[1].String() + p.mode = "attach" + return nil +} + +func parseVNCProxyOptionalStrings(args []js.Value, p *vncProxyParams) error { + if len(args) > 2 && args[2].Type() == js.TypeString { + p.mode = args[2].String() + } + if p.mode != "attach" && p.mode != "session" { + p.rejectViaPromise = true + return fmt.Errorf("invalid mode %q: expected \"attach\" or \"session\"", p.mode) + } + if len(args) > 3 && args[3].Type() == js.TypeString { + p.username = args[3].String() + } + if len(args) > 4 && args[4].Type() == js.TypeString { + p.jwt = args[4].String() + } + return nil +} + +func parseVNCProxyOptionalNumbers(args []js.Value, p *vncProxyParams) error { + if len(args) > 5 && args[5].Type() == js.TypeNumber { + v := args[5].Int() + if v < 0 || v > 0xFFFFFFFF { + p.rejectViaPromise = true + return fmt.Errorf("invalid sessionID %d: must be 0..0xFFFFFFFF", v) } - if args[1].Type() != js.TypeString { - return createPromise(func(resolve, reject js.Value) { - reject.Invoke(js.ValueOf("port parameter must be a string")) - }) + p.sessionID = uint32(v) + } + // width=0 / height=0 mean "use server default"; reject only out-of-range + // non-zero values so attach mode (which omits width/height) still works. + if len(args) > 6 && args[6].Type() == js.TypeNumber { + v := args[6].Int() + if v < 0 || v > 0xFFFF { + p.rejectViaPromise = true + return fmt.Errorf("invalid width %d: must be 0..65535", v) } - - proxy := rdp.NewRDCleanPathProxy(client) - return proxy.CreateProxy(args[0].String(), args[1].String()) - }) + p.width = uint16(v) + } + if len(args) > 7 && args[7].Type() == js.TypeNumber { + v := args[7].Int() + if v < 0 || v > 0xFFFF { + p.rejectViaPromise = true + return fmt.Errorf("invalid height %d: must be 0..65535", v) + } + p.height = uint16(v) + } + return nil } // getStatusOverview is a helper to get the status overview @@ -676,7 +780,7 @@ func createClientObject(client *netbird.Client) js.Value { obj["detectSSHServerType"] = createDetectSSHServerMethod(client) obj["createSSHConnection"] = createSSHMethod(client) obj["proxyRequest"] = createProxyRequestMethod(client) - obj["createRDPProxy"] = createRDPProxyMethod(client) + obj["createVNCProxy"] = createVNCProxyMethod(client) obj["status"] = createStatusMethod(client) obj["statusSummary"] = createStatusSummaryMethod(client) obj["statusDetail"] = createStatusDetailMethod(client) diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go new file mode 100644 index 00000000000..5d9b58a2ad1 --- /dev/null +++ b/client/wasm/internal/vnc/proxy.go @@ -0,0 +1,427 @@ +//go:build js + +package vnc + +import ( + "context" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "syscall/js" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + vncProxyHost = "vnc.proxy.local" + vncProxyScheme = "ws" + vncDialTimeout = 15 * time.Second + + // Connection modes matching server/server.go constants. + modeAttach byte = 0 + modeSession byte = 1 +) + +// VNCProxy bridges WebSocket connections from noVNC in the browser +// to TCP VNC server connections through the NetBird tunnel. +type VNCProxy struct { + nbClient interface { + Dial(ctx context.Context, network, address string) (net.Conn, error) + } + activeConnections map[string]*vncConnection + destinations map[string]vncDestination + // pendingHandlers holds the js.Func for handleVNCWebSocket_ between + // CreateProxy and handleWebSocketConnection so we can move it onto the + // vncConnection for later release. + pendingHandlers map[string]js.Func + mu sync.Mutex + nextID atomic.Uint64 +} + +type vncDestination struct { + address string + mode byte + username string + jwt string + sessionID uint32 // Windows session ID (0 = auto/console) + width uint16 // Requested viewport width for session mode (0 = default) + height uint16 // Requested viewport height for session mode (0 = default) +} + +type vncConnection struct { + id string + destination vncDestination + mu sync.Mutex + vncConn net.Conn + wsHandlers js.Value + ctx context.Context + cancel context.CancelFunc + // Go-side callbacks exposed to JS. js.FuncOf pins the Go closure in a + // global handle map and MUST be released, otherwise every connection + // leaks the Go memory the closure captures. + wsHandlerFn js.Func + onMessageFn js.Func + onCloseFn js.Func +} + +// NewVNCProxy creates a new VNC proxy. +func NewVNCProxy(client interface { + Dial(ctx context.Context, network, address string) (net.Conn, error) +}) *VNCProxy { + return &VNCProxy{ + nbClient: client, + activeConnections: make(map[string]*vncConnection), + } +} + +// ProxyRequest bundles the per-call parameters for CreateProxy so the JS +// boundary doesn't drown callers in a wide positional argument list. +type ProxyRequest struct { + Hostname string + Port string + Mode string + Username string + JWT string + SessionID uint32 + Width uint16 + Height uint16 +} + +// CreateProxy creates a new proxy endpoint for the given VNC destination. +// req.Mode is "attach" (capture current display) or "session" (virtual session). +// req.Username is required for session mode. req.Width/Height request the +// virtual display geometry for session mode; 0 means use the server default. +// Returns a JS Promise that resolves to the WebSocket proxy URL. +func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { + hostname, port, mode, username, jwt := req.Hostname, req.Port, req.Mode, req.Username, req.JWT + sessionID, width, height := req.SessionID, req.Width, req.Height + address := net.JoinHostPort(hostname, port) + + var m byte + if mode == "session" { + m = modeSession + } + + dest := vncDestination{ + address: address, + mode: m, + username: username, + jwt: jwt, + sessionID: sessionID, + width: width, + height: height, + } + return p.newProxyPromise(address, mode, username, dest) +} + +// newProxyPromise wraps the JS Promise creation + executor lifecycle so +// CreateProxy stays a thin parameter-bundling entrypoint. +func (p *VNCProxy) newProxyPromise(address, mode, username string, dest vncDestination) js.Value { + + var executor js.Func + executor = js.FuncOf(func(_ js.Value, args []js.Value) any { + resolve := args[0] + + go func() { + defer executor.Release() + + proxyID := fmt.Sprintf("vnc_proxy_%d", p.nextID.Add(1)) + + p.mu.Lock() + if p.destinations == nil { + p.destinations = make(map[string]vncDestination) + } + p.destinations[proxyID] = dest + p.mu.Unlock() + + proxyURL := fmt.Sprintf("%s://%s/%s", vncProxyScheme, vncProxyHost, proxyID) + + handlerFn := js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 1 { + return js.ValueOf("error: requires WebSocket argument") + } + p.handleWebSocketConnection(args[0], proxyID) + return nil + }) + p.mu.Lock() + if p.pendingHandlers == nil { + p.pendingHandlers = make(map[string]js.Func) + } + p.pendingHandlers[proxyID] = handlerFn + p.mu.Unlock() + js.Global().Set(fmt.Sprintf("handleVNCWebSocket_%s", proxyID), handlerFn) + + log.Infof("created VNC proxy: %s -> %s (mode=%s, user=%s)", proxyURL, address, mode, username) + resolve.Invoke(proxyURL) + }() + + return nil + }) + return js.Global().Get("Promise").New(executor) +} + +func (p *VNCProxy) handleWebSocketConnection(ws js.Value, proxyID string) { + p.mu.Lock() + dest, ok := p.destinations[proxyID] + handlerFn := p.pendingHandlers[proxyID] + delete(p.pendingHandlers, proxyID) + p.mu.Unlock() + + if !ok { + log.Errorf("no destination for VNC proxy %s", proxyID) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + + conn := &vncConnection{ + id: proxyID, + destination: dest, + wsHandlers: ws, + ctx: ctx, + cancel: cancel, + wsHandlerFn: handlerFn, + } + + p.mu.Lock() + p.activeConnections[proxyID] = conn + p.mu.Unlock() + + p.setupWebSocketHandlers(ws, conn) + go p.connectToVNC(conn) + + log.Infof("VNC proxy WebSocket connection established for %s", proxyID) +} + +func (p *VNCProxy) setupWebSocketHandlers(ws js.Value, conn *vncConnection) { + conn.onMessageFn = js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 1 { + return nil + } + data := args[0] + go p.handleWebSocketMessage(conn, data) + return nil + }) + ws.Set("onGoMessage", conn.onMessageFn) + + conn.onCloseFn = js.FuncOf(func(_ js.Value, _ []js.Value) any { + log.Debug("VNC WebSocket closed by JavaScript") + conn.cancel() + return nil + }) + ws.Set("onGoClose", conn.onCloseFn) +} + +func (p *VNCProxy) handleWebSocketMessage(conn *vncConnection, data js.Value) { + if !data.InstanceOf(js.Global().Get("Uint8Array")) { + return + } + + length := data.Get("length").Int() + buf := make([]byte, length) + js.CopyBytesToGo(buf, data) + + conn.mu.Lock() + vncConn := conn.vncConn + conn.mu.Unlock() + + if vncConn == nil { + return + } + + if _, err := vncConn.Write(buf); err != nil { + log.Debugf("write to VNC server: %v", err) + } +} + +func (p *VNCProxy) connectToVNC(conn *vncConnection) { + ctx, cancel := context.WithTimeout(conn.ctx, vncDialTimeout) + defer cancel() + + vncConn, err := p.nbClient.Dial(ctx, "tcp", conn.destination.address) + if err != nil { + log.Errorf("VNC connect to %s: %v", conn.destination.address, err) + // Close the WebSocket so noVNC fires a disconnect event. + if conn.wsHandlers.Get("close").Truthy() { + conn.wsHandlers.Call("close", 1006, fmt.Sprintf("connect to peer: %v", err)) + } + p.cleanupConnection(conn) + return + } + conn.mu.Lock() + conn.vncConn = vncConn + conn.mu.Unlock() + + // Send the NetBird VNC session header before the RFB handshake. + if err := p.sendSessionHeader(vncConn, conn.destination); err != nil { + log.Errorf("send VNC session header: %v", err) + if conn.wsHandlers.Get("close").Truthy() { + conn.wsHandlers.Call("close", 1006, fmt.Sprintf("send session header: %v", err)) + } + p.cleanupConnection(conn) + return + } + + // WS→TCP is handled by the onGoMessage handler set in setupWebSocketHandlers, + // which writes directly to the VNC connection as data arrives from JS. + // Only the TCP→WS direction needs a read loop here. + go p.forwardConnToWS(conn) + + <-conn.ctx.Done() + p.cleanupConnection(conn) +} + +// sendSessionHeader writes mode, username, JWT, Windows session ID, and the +// requested viewport size to the VNC server. +// Format: [mode:1] [username_len:2] [username:N] [jwt_len:2] [jwt:N] +// +// [session_id:4] [width:2] [height:2] +func (p *VNCProxy) sendSessionHeader(conn net.Conn, dest vncDestination) error { + usernameBytes := []byte(dest.username) + jwtBytes := []byte(dest.jwt) + if len(usernameBytes) > 0xFFFF { + return fmt.Errorf("username too long: %d bytes (max %d)", len(usernameBytes), 0xFFFF) + } + if len(jwtBytes) > 0xFFFF { + return fmt.Errorf("jwt too long: %d bytes (max %d)", len(jwtBytes), 0xFFFF) + } + hdr := make([]byte, 3+len(usernameBytes)+2+len(jwtBytes)+4+4) + hdr[0] = dest.mode + hdr[1] = byte(len(usernameBytes) >> 8) + hdr[2] = byte(len(usernameBytes)) + off := 3 + copy(hdr[off:], usernameBytes) + off += len(usernameBytes) + hdr[off] = byte(len(jwtBytes) >> 8) + hdr[off+1] = byte(len(jwtBytes)) + off += 2 + copy(hdr[off:], jwtBytes) + off += len(jwtBytes) + hdr[off] = byte(dest.sessionID >> 24) + hdr[off+1] = byte(dest.sessionID >> 16) + hdr[off+2] = byte(dest.sessionID >> 8) + hdr[off+3] = byte(dest.sessionID) + off += 4 + hdr[off] = byte(dest.width >> 8) + hdr[off+1] = byte(dest.width) + hdr[off+2] = byte(dest.height >> 8) + hdr[off+3] = byte(dest.height) + + for off := 0; off < len(hdr); { + n, err := conn.Write(hdr[off:]) + if err != nil { + return fmt.Errorf("write session header: %w", err) + } + off += n + } + return nil +} + +func (p *VNCProxy) forwardConnToWS(conn *vncConnection) { + buf := make([]byte, 32*1024) + + for { + if conn.ctx.Err() != nil { + return + } + vc, ok := conn.snapshotVNC() + if !ok { + return + } + if err := vc.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { + log.Debugf("set VNC read deadline: %v", err) + } + n, err := vc.Read(buf) + if err != nil { + if p.handleConnReadError(conn, err) { + return + } + continue + } + if n > 0 { + p.sendToWebSocket(conn, buf[:n]) + } + } +} + +// snapshotVNC returns the current vncConn under conn.mu, with ok=false when +// the connection has already been cleaned up. +func (c *vncConnection) snapshotVNC() (net.Conn, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if c.vncConn == nil { + return nil, false + } + return c.vncConn, true +} + +// handleConnReadError classifies an error from the VNC read loop. Returns +// true if the caller should exit; false to retry (transient timeout). +func (p *VNCProxy) handleConnReadError(conn *vncConnection, err error) bool { + if conn.ctx.Err() != nil { + return true + } + if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + // Read timeout: connection might be stale. The next iteration will + // fail too and trigger the close path. + return false + } + if err != io.EOF { + log.Debugf("read from VNC connection: %v", err) + } + // Close the WebSocket to notify noVNC, and cancel the local context so + // cleanupConnection isn't left waiting on the JS close callback that + // may never fire on hard errors. + if conn.wsHandlers.Get("close").Truthy() { + conn.wsHandlers.Call("close", 1006, "VNC connection lost") + } + conn.cancel() + return true +} + +func (p *VNCProxy) sendToWebSocket(conn *vncConnection, data []byte) { + if conn.wsHandlers.Get("receiveFromGo").Truthy() { + uint8Array := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(uint8Array, data) + conn.wsHandlers.Call("receiveFromGo", uint8Array.Get("buffer")) + } else if conn.wsHandlers.Get("send").Truthy() { + uint8Array := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(uint8Array, data) + conn.wsHandlers.Call("send", uint8Array.Get("buffer")) + } +} + +func (p *VNCProxy) cleanupConnection(conn *vncConnection) { + log.Debugf("cleaning up VNC connection %s", conn.id) + conn.cancel() + + conn.mu.Lock() + vncConn := conn.vncConn + conn.vncConn = nil + conn.mu.Unlock() + + if vncConn != nil { + if err := vncConn.Close(); err != nil { + log.Debugf("close VNC connection: %v", err) + } + } + + // Remove the global JS handler registered in CreateProxy. + globalName := fmt.Sprintf("handleVNCWebSocket_%s", conn.id) + js.Global().Delete(globalName) + + // Release all js.Func handles; js.FuncOf pins the Go closure and the + // allocations it captures until Release is called. + conn.wsHandlerFn.Release() + conn.onMessageFn.Release() + conn.onCloseFn.Release() + + p.mu.Lock() + delete(p.activeConnections, conn.id) + delete(p.destinations, conn.id) + delete(p.pendingHandlers, conn.id) + p.mu.Unlock() +} diff --git a/go.mod b/go.mod index 7c1a95e79f4..5f4d71291dd 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,8 @@ require ( github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 github.com/jackc/pgx/v5 v5.5.5 + github.com/jezek/xgb v1.3.0 + github.com/kirides/go-d3d v1.0.1 github.com/libdns/route53 v1.5.0 github.com/libp2p/go-nat v0.2.0 github.com/libp2p/go-netroute v0.4.0 diff --git a/go.sum b/go.sum index 53789f49dd3..e8db1a191f0 100644 --- a/go.sum +++ b/go.sum @@ -378,6 +378,8 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jezek/xgb v1.3.0 h1:Wa1pn4GVtcmNVAVB6/pnQVJ7xPFZVZ/W1Tc27msDhgI= +github.com/jezek/xgb v1.3.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -396,6 +398,8 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6U github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kirides/go-d3d v1.0.1 h1:ZDANfvo34vskBMET1uwUUMNw8545Kbe8qYSiRwlNIuA= +github.com/kirides/go-d3d v1.0.1/go.mod h1:99AjD+5mRTFEnkpRWkwq8UYMQDljGIIvLn2NyRdVImY= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 12402b420e8..ea80623b863 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -98,10 +98,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set sshConfig := &proto.SSHConfig{ SshEnabled: peer.SSHEnabled || enableSSH, - } - - if sshConfig.SshEnabled { - sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig) + JwtConfig: buildJWTConfig(httpConfig, deviceFlowConfig), } peerConfig := &proto.PeerConfig{ @@ -134,13 +131,14 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() useSourcePrefixes := peer.SupportsSourcePrefixes() + peerConfig := toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH) response := &proto.SyncResponse{ - PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), + PeerConfig: peerConfig, NetworkMap: &proto.NetworkMap{ Serial: networkMap.Network.CurrentSerial(), Routes: toProtocolRoutes(networkMap.Routes), DNSConfig: toProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), - PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), + PeerConfig: peerConfig, }, Checks: toProtocolChecks(ctx, checks), } @@ -149,8 +147,6 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb extendedConfig := integrationsConfig.ExtendNetBirdConfig(peer.ID, peerGroups, nbConfig, extraSettings) response.NetbirdConfig = extendedConfig - response.NetworkMap.PeerConfig = response.PeerConfig - remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers)) remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) response.RemotePeers = remotePeers @@ -176,15 +172,21 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.ForwardingRules = forwardingRules } + userIDClaim := auth.DefaultUserIDClaim + if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { + userIDClaim = httpConfig.AuthUserIDClaim + } + if networkMap.AuthorizedUsers != nil { hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) - userIDClaim := auth.DefaultUserIDClaim - if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { - userIDClaim = httpConfig.AuthUserIDClaim - } response.NetworkMap.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} } + if networkMap.VNCAuthorizedUsers != nil { + hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.VNCAuthorizedUsers) + response.NetworkMap.VncAuth = &proto.VNCAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} + } + return response } diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 70024bac6d8..b2e55649984 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -673,6 +673,7 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee RosenpassEnabled: meta.GetFlags().GetRosenpassEnabled(), RosenpassPermissive: meta.GetFlags().GetRosenpassPermissive(), ServerSSHAllowed: meta.GetFlags().GetServerSSHAllowed(), + ServerVNCAllowed: meta.GetFlags().GetServerVNCAllowed(), DisableClientRoutes: meta.GetFlags().GetDisableClientRoutes(), DisableServerRoutes: meta.GetFlags().GetDisableServerRoutes(), DisableDNS: meta.GetFlags().GetDisableDNS(), diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 91026a37496..5e566d38b66 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -514,7 +514,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) PortRanges: []types.RulePortRange{portRange}, }}, } - if protocol == types.PolicyRuleProtocolNetbirdSSH { + if protocol == types.PolicyRuleProtocolNetbirdSSH || protocol == types.PolicyRuleProtocolNetbirdVNC { policy.Rules[0].AuthorizedUser = userAuth.UserId } @@ -610,6 +610,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled, RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive, ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed, + ServerVncAllowed: &peer.Meta.Flags.ServerVNCAllowed, }, } @@ -665,6 +666,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled, RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive, ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed, + ServerVncAllowed: &peer.Meta.Flags.ServerVNCAllowed, }, } } diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 1b77ea3358f..60b89138589 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -714,6 +714,7 @@ func Test_LoginPerformance(t *testing.T) { RosenpassEnabled: meta.GetFlags().GetRosenpassEnabled(), RosenpassPermissive: meta.GetFlags().GetRosenpassPermissive(), ServerSSHAllowed: meta.GetFlags().GetServerSSHAllowed(), + ServerVNCAllowed: meta.GetFlags().GetServerVNCAllowed(), DisableClientRoutes: meta.GetFlags().GetDisableClientRoutes(), DisableServerRoutes: meta.GetFlags().GetDisableServerRoutes(), DisableDNS: meta.GetFlags().GetDisableDNS(), diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 17df761a1b0..70e94bb0879 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -116,6 +116,7 @@ type Flags struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed bool + ServerVNCAllowed bool DisableClientRoutes bool DisableServerRoutes bool @@ -126,6 +127,7 @@ type Flags struct { DisableIPv6 bool LazyConnectionEnabled bool + } // PeerSystemMeta is a metadata of a Peer machine system @@ -410,6 +412,7 @@ func (f Flags) isEqual(other Flags) bool { return f.RosenpassEnabled == other.RosenpassEnabled && f.RosenpassPermissive == other.RosenpassPermissive && f.ServerSSHAllowed == other.ServerSSHAllowed && + f.ServerVNCAllowed == other.ServerVNCAllowed && f.DisableClientRoutes == other.DisableClientRoutes && f.DisableServerRoutes == other.DisableServerRoutes && f.DisableDNS == other.DisableDNS && diff --git a/management/server/policy_test.go b/management/server/policy_test.go index 1eae07e79b8..dadcd6c75ab 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -246,14 +246,14 @@ func TestAccount_getPeersByPolicy(t *testing.T) { t.Run("check that all peers get map", func(t *testing.T) { for _, p := range account.Peers { - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers()) assert.GreaterOrEqual(t, len(peers), 1, "minimum number peers should present") assert.GreaterOrEqual(t, len(firewallRules), 1, "minimum number of firewall rules should present") } }) t.Run("check first peer map details", func(t *testing.T) { - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 8) assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerC"]) @@ -509,7 +509,7 @@ func TestAccount_getPeersByPolicy(t *testing.T) { }) t.Run("check port ranges support for older peers", func(t *testing.T) { - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 1) assert.Contains(t, peers, account.Peers["peerI"]) @@ -635,7 +635,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { } t.Run("check first peer map", func(t *testing.T) { - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerC"]) expectedFirewallRules := []*types.FirewallRule{ @@ -665,7 +665,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { }) t.Run("check second peer map", func(t *testing.T) { - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerB"]) expectedFirewallRules := []*types.FirewallRule{ @@ -697,7 +697,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { account.Policies[1].Rules[0].Bidirectional = false t.Run("check first peer map directional only", func(t *testing.T) { - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerC"]) expectedFirewallRules := []*types.FirewallRule{ @@ -719,7 +719,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { }) t.Run("check second peer map directional only", func(t *testing.T) { - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerB"]) expectedFirewallRules := []*types.FirewallRule{ @@ -917,7 +917,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { t.Run("verify peer's network map with default group peer list", func(t *testing.T) { // peerB doesn't fulfill the NB posture check but is included in the destination group Swarm, // will establish a connection with all source peers satisfying the NB posture check. - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -927,7 +927,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerC satisfy the NB posture check, should establish connection to all destination group peer's // We expect a single permissive firewall rule which all outgoing connections - peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, firewallRules, 7) expectedFirewallRules := []*types.FirewallRule{ @@ -992,7 +992,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -1002,7 +1002,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerI doesn't fulfill the OS version posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -1017,19 +1017,19 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerB doesn't satisfy the NB posture check, and doesn't exist in destination group peer's // no connection should be established to any peer of destination group - peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 0) assert.Len(t, firewallRules, 0) // peerI doesn't satisfy the OS version posture check, and doesn't exist in destination group peer's // no connection should be established to any peer of destination group - peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 0) assert.Len(t, firewallRules, 0) // peerC satisfy the NB posture check, should establish connection to all destination group peer's // We expect a single permissive firewall rule which all outgoing connections - peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, firewallRules, len(account.Groups["GroupSwarm"].Peers)) @@ -1044,14 +1044,14 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 3) assert.Len(t, firewallRules, 3) assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerC"]) assert.Contains(t, peers, account.Peers["peerD"]) - peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 5) // assert peers from Group Swarm assert.Contains(t, peers, account.Peers["peerD"]) diff --git a/management/server/types/account.go b/management/server/types/account.go index 870333a603c..59c64d1ff17 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -51,6 +51,9 @@ const ( // defaultSSHPortString defines the standard SSH port number as a string, commonly used for default SSH connections. defaultSSHPortString = "22" defaultSSHPortNumber = 22 + + // vncInternalPort is the internal port the VNC server listens on (behind DNAT from 5900). + vncInternalPort = 25900 ) type supportedFeatures struct { @@ -164,6 +167,7 @@ func (a *Account) GetGroup(groupID string) *Group { return a.Groups[groupID] } + func (a *Account) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peer *nbpeer.Peer, @@ -845,94 +849,78 @@ func (a *Account) UserGroupsRemoveFromPeers(userID string, groups ...string) map // GetPeerConnectionResources for a given peer // // This function returns the list of peers and firewall rules that are applicable to a given peer. -func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { +func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, map[string]map[string]struct{}, bool) { generateResources, getAccumulatedResources := a.connResourcesGenerator(ctx, peer) - authorizedUsers := make(map[string]map[string]struct{}) // machine user to list of userIDs - sshEnabled := false + ctxState := &peerConnResolveState{ + authorizedUsers: make(map[string]map[string]struct{}), + vncAuthorizedUsers: make(map[string]map[string]struct{}), + } for _, policy := range a.Policies { if !policy.Enabled { continue } - for _, rule := range policy.Rules { if !rule.Enabled { continue } + a.applyPolicyRule(ctx, peer, rule, policy.SourcePostureChecks, validatedPeersMap, groupIDToUserIDs, generateResources, ctxState) + } + } - var sourcePeers, destinationPeers []*nbpeer.Peer - var peerInSources, peerInDestinations bool - - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { - sourcePeers, peerInSources = a.getPeerFromResource(rule.SourceResource, peer.ID) - } else { - sourcePeers, peerInSources = a.getAllPeersFromGroups(ctx, rule.Sources, peer.ID, policy.SourcePostureChecks, validatedPeersMap) - } - - if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { - destinationPeers, peerInDestinations = a.getPeerFromResource(rule.DestinationResource, peer.ID) - } else { - destinationPeers, peerInDestinations = a.getAllPeersFromGroups(ctx, rule.Destinations, peer.ID, nil, validatedPeersMap) - } - - if rule.Bidirectional { - if peerInSources { - generateResources(rule, destinationPeers, FirewallRuleDirectionIN) - } - if peerInDestinations { - generateResources(rule, sourcePeers, FirewallRuleDirectionOUT) - } - } - - if peerInSources { - generateResources(rule, destinationPeers, FirewallRuleDirectionOUT) - } + peers, fwRules := getAccumulatedResources() + return peers, fwRules, ctxState.authorizedUsers, ctxState.vncAuthorizedUsers, ctxState.sshEnabled +} - if peerInDestinations { - generateResources(rule, sourcePeers, FirewallRuleDirectionIN) - } +func (a *Account) applyPolicyRule( + ctx context.Context, + peer *nbpeer.Peer, + rule *PolicyRule, + sourcePostureChecks []string, + validatedPeersMap map[string]struct{}, + groupIDToUserIDs map[string][]string, + generateResources func(*PolicyRule, []*nbpeer.Peer, int), + state *peerConnResolveState, +) { + sourcePeers, peerInSources := a.resolveRuleEndpoint(ctx, rule.SourceResource, rule.Sources, peer.ID, sourcePostureChecks, validatedPeersMap) + destinationPeers, peerInDestinations := a.resolveRuleEndpoint(ctx, rule.DestinationResource, rule.Destinations, peer.ID, nil, validatedPeersMap) + + cb := ruleAuthCallbacks{ + collectSSHUsers: func(r *PolicyRule, t map[string]map[string]struct{}) { + a.collectAuthorizedUsers(ctx, r, groupIDToUserIDs, t) + }, + collectVNCUsers: func(r *PolicyRule, t map[string]map[string]struct{}) { + a.collectAuthorizedUsers(ctx, r, groupIDToUserIDs, t) + }, + getAllowedUserIDs: a.getAllowedUserIDs, + } + applyResolvedRuleToState(rule, sourcePeers, destinationPeers, peerInSources, peerInDestinations, peer.SSHEnabled, generateResources, cb, state) +} - if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH { - sshEnabled = true - switch { - case len(rule.AuthorizedGroups) > 0: - for groupID, localUsers := range rule.AuthorizedGroups { - userIDs, ok := groupIDToUserIDs[groupID] - if !ok { - log.WithContext(ctx).Tracef("no user IDs found for group ID %s", groupID) - continue - } - - if len(localUsers) == 0 { - localUsers = []string{auth.Wildcard} - } - - for _, localUser := range localUsers { - if authorizedUsers[localUser] == nil { - authorizedUsers[localUser] = make(map[string]struct{}) - } - for _, userID := range userIDs { - authorizedUsers[localUser][userID] = struct{}{} - } - } - } - case rule.AuthorizedUser != "": - if authorizedUsers[auth.Wildcard] == nil { - authorizedUsers[auth.Wildcard] = make(map[string]struct{}) - } - authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{} - default: - authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() - } - } else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && peer.SSHEnabled { - sshEnabled = true - authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() - } - } +func (a *Account) resolveRuleEndpoint( + ctx context.Context, + resource Resource, + groups []string, + peerID string, + postureChecks []string, + validatedPeersMap map[string]struct{}, +) ([]*nbpeer.Peer, bool) { + if resource.Type == ResourceTypePeer && resource.ID != "" { + return a.getPeerFromResource(resource, peerID) } + return a.getAllPeersFromGroups(ctx, groups, peerID, postureChecks, validatedPeersMap) +} - peers, fwRules := getAccumulatedResources() - return peers, fwRules, authorizedUsers, sshEnabled +// collectAuthorizedUsers populates the target map with authorized user mappings from the rule. +func (a *Account) collectAuthorizedUsers(ctx context.Context, rule *PolicyRule, groupIDToUserIDs map[string][]string, target map[string]map[string]struct{}) { + switch { + case len(rule.AuthorizedGroups) > 0: + mergeAuthorizedGroupUsers(ctx, rule.AuthorizedGroups, groupIDToUserIDs, target) + case rule.AuthorizedUser != "": + ensureWildcardUser(target, rule.AuthorizedUser) + default: + target[auth.Wildcard] = a.getAllowedUserIDs() + } } func (a *Account) getAllowedUserIDs() map[string]struct{} { @@ -967,38 +955,35 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists[peer.ID] = struct{}{} } - protocol := rule.Protocol - if protocol == PolicyRuleProtocolNetbirdSSH { - protocol = PolicyRuleProtocolTCP - } + effectiveRule, protocol := normalizePolicyRuleProtocol(rule) fr := FirewallRule{ - PolicyID: rule.ID, + PolicyID: effectiveRule.ID, PeerIP: peer.IP.String(), Direction: direction, - Action: string(rule.Action), + Action: string(effectiveRule.Action), Protocol: string(protocol), } - ruleID := rule.ID + fr.PeerIP + strconv.Itoa(direction) + - fr.Protocol + fr.Action + strings.Join(rule.Ports, ",") + ruleID := effectiveRule.ID + fr.PeerIP + strconv.Itoa(direction) + + fr.Protocol + fr.Action + strings.Join(effectiveRule.Ports, ",") if _, ok := rulesExists[ruleID]; ok { continue } rulesExists[ruleID] = struct{}{} - if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { + if len(effectiveRule.Ports) == 0 && len(effectiveRule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, expandPortsAndRanges(fr, effectiveRule, targetPeer)...) } - rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{ + rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, effectiveRule, firewallRuleContext{ direction: direction, dirStr: strconv.Itoa(direction), protocolStr: string(protocol), - actionStr: string(rule.Action), - portsJoined: strings.Join(rule.Ports, ","), + actionStr: string(effectiveRule.Action), + portsJoined: strings.Join(effectiveRule.Ports, ","), }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { diff --git a/management/server/types/network.go b/management/server/types/network.go index fe67bfd9716..60236444f32 100644 --- a/management/server/types/network.go +++ b/management/server/types/network.go @@ -48,6 +48,7 @@ type NetworkMap struct { RoutesFirewallRules []*RouteFirewallRule ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} + VNCAuthorizedUsers map[string]map[string]struct{} EnableSSH bool } diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index 3a7e20ec527..a0373f0c37f 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -109,7 +109,8 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { peerGroups := c.GetPeerGroups(targetPeerID) - aclPeers, firewallRules, authorizedUsers, sshEnabled := c.getPeerConnectionResources(targetPeerID) + connRes := c.getPeerConnectionResources(targetPeerID) + aclPeers := connRes.peers peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers) @@ -162,105 +163,98 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { Routes: append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...), DNSConfig: dnsUpdate, OfflinePeers: expiredPeers, - FirewallRules: firewallRules, + FirewallRules: connRes.firewallRules, RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...), - AuthorizedUsers: authorizedUsers, - EnableSSH: sshEnabled, + AuthorizedUsers: connRes.authorizedUsers, + VNCAuthorizedUsers: connRes.vncAuthorizedUsers, + EnableSSH: connRes.sshEnabled, } } -func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { +// peerConnectionResult holds the output of getPeerConnectionResources. +type peerConnectionResult struct { + peers []*nbpeer.Peer + firewallRules []*FirewallRule + authorizedUsers map[string]map[string]struct{} + vncAuthorizedUsers map[string]map[string]struct{} + sshEnabled bool +} + +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) peerConnectionResult { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { - return nil, nil, nil, false + return peerConnectionResult{} } generateResources, getAccumulatedResources := c.connResourcesGenerator(targetPeer) - authorizedUsers := make(map[string]map[string]struct{}) - sshEnabled := false + state := &peerConnResolveState{ + authorizedUsers: make(map[string]map[string]struct{}), + vncAuthorizedUsers: make(map[string]map[string]struct{}), + } for _, policy := range c.Policies { if !policy.Enabled { continue } - for _, rule := range policy.Rules { if !rule.Enabled { continue } + c.applyPolicyRule(rule, policy.SourcePostureChecks, targetPeer, targetPeerID, generateResources, state) + } + } - var sourcePeers, destinationPeers []*nbpeer.Peer - var peerInSources, peerInDestinations bool - - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { - sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID) - } else { - sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks) - } - - if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { - destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID) - } else { - destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil) - } - - if rule.Bidirectional { - if peerInSources { - generateResources(rule, destinationPeers, FirewallRuleDirectionIN) - } - if peerInDestinations { - generateResources(rule, sourcePeers, FirewallRuleDirectionOUT) - } - } + peers, fwRules := getAccumulatedResources() + return peerConnectionResult{ + peers: peers, + firewallRules: fwRules, + authorizedUsers: state.authorizedUsers, + vncAuthorizedUsers: state.vncAuthorizedUsers, + sshEnabled: state.sshEnabled, + } +} - if peerInSources { - generateResources(rule, destinationPeers, FirewallRuleDirectionOUT) - } +func (c *NetworkMapComponents) applyPolicyRule( + rule *PolicyRule, + sourcePostureChecks []string, + targetPeer *nbpeer.Peer, + targetPeerID string, + generateResources func(*PolicyRule, []*nbpeer.Peer, int), + state *peerConnResolveState, +) { + sourcePeers, peerInSources := c.resolveRuleEndpoint(rule.SourceResource, rule.Sources, targetPeerID, sourcePostureChecks) + destinationPeers, peerInDestinations := c.resolveRuleEndpoint(rule.DestinationResource, rule.Destinations, targetPeerID, nil) - if peerInDestinations { - generateResources(rule, sourcePeers, FirewallRuleDirectionIN) - } + cb := ruleAuthCallbacks{ + collectSSHUsers: c.collectAuthorizedUsers, + collectVNCUsers: c.collectAuthorizedUsers, + getAllowedUserIDs: c.getAllowedUserIDs, + } + applyResolvedRuleToState(rule, sourcePeers, destinationPeers, peerInSources, peerInDestinations, targetPeer.SSHEnabled, generateResources, cb, state) +} - if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH { - sshEnabled = true - switch { - case len(rule.AuthorizedGroups) > 0: - for groupID, localUsers := range rule.AuthorizedGroups { - userIDs, ok := c.GroupIDToUserIDs[groupID] - if !ok { - continue - } - - if len(localUsers) == 0 { - localUsers = []string{auth.Wildcard} - } - - for _, localUser := range localUsers { - if authorizedUsers[localUser] == nil { - authorizedUsers[localUser] = make(map[string]struct{}) - } - for _, userID := range userIDs { - authorizedUsers[localUser][userID] = struct{}{} - } - } - } - case rule.AuthorizedUser != "": - if authorizedUsers[auth.Wildcard] == nil { - authorizedUsers[auth.Wildcard] = make(map[string]struct{}) - } - authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{} - default: - authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() - } - } else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { - sshEnabled = true - authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() - } - } +func (c *NetworkMapComponents) resolveRuleEndpoint( + resource Resource, + groups []string, + peerID string, + postureChecks []string, +) ([]*nbpeer.Peer, bool) { + if resource.Type == ResourceTypePeer && resource.ID != "" { + return c.getPeerFromResource(resource, peerID) } + return c.getAllPeersFromGroups(groups, peerID, postureChecks) +} - peers, fwRules := getAccumulatedResources() - return peers, fwRules, authorizedUsers, sshEnabled +// collectAuthorizedUsers populates the target map with authorized user mappings from the rule. +func (c *NetworkMapComponents) collectAuthorizedUsers(rule *PolicyRule, target map[string]map[string]struct{}) { + switch { + case len(rule.AuthorizedGroups) > 0: + mergeAuthorizedGroupUsers(context.Background(), rule.AuthorizedGroups, c.GroupIDToUserIDs, target) + case rule.AuthorizedUser != "": + ensureWildcardUser(target, rule.AuthorizedUser) + default: + target[auth.Wildcard] = c.getAllowedUserIDs() + } } func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { @@ -279,10 +273,8 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( peers := make([]*nbpeer.Peer, 0) return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { - protocol := rule.Protocol - if protocol == PolicyRuleProtocolNetbirdSSH { - protocol = PolicyRuleProtocolTCP - } + effectiveRule, protocol := normalizePolicyRuleProtocol(rule) + rule = effectiveRule protocolStr := string(protocol) actionStr := string(rule.Action) @@ -557,7 +549,6 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute return enabledRoutes, disabledRoutes } - func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { var filteredRoutes []*route.Route for _, r := range routes { diff --git a/management/server/types/policy.go b/management/server/types/policy.go index d410aec8ddf..5ea0bc0ade2 100644 --- a/management/server/types/policy.go +++ b/management/server/types/policy.go @@ -25,6 +25,8 @@ const ( PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp") // PolicyRuleProtocolNetbirdSSH type of traffic PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh") + // PolicyRuleProtocolNetbirdVNC type of traffic + PolicyRuleProtocolNetbirdVNC = PolicyRuleProtocolType("netbird-vnc") ) const ( @@ -209,6 +211,8 @@ func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'") case "netbird-ssh": return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil + case "netbird-vnc": + return PolicyRuleProtocolNetbirdVNC, RulePortRange{Start: vncInternalPort, End: vncInternalPort}, nil default: return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr) } diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go new file mode 100644 index 00000000000..6452a5593c8 --- /dev/null +++ b/management/server/types/policy_authorized_users.go @@ -0,0 +1,160 @@ +package types + +import ( + "context" + "strconv" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ssh/auth" + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +// peerConnResolveState carries the in-progress maps mutated by per-rule +// resolution while walking an account's policies. +type peerConnResolveState struct { + authorizedUsers map[string]map[string]struct{} + vncAuthorizedUsers map[string]map[string]struct{} + sshEnabled bool +} + +// ruleAuthCallbacks lets Account and NetworkMapComponents share the per-rule +// direction-and-auth logic while keeping their own context/state plumbing for +// authorized-user collection and allowed-user lookups. +type ruleAuthCallbacks struct { + collectSSHUsers func(*PolicyRule, map[string]map[string]struct{}) + collectVNCUsers func(*PolicyRule, map[string]map[string]struct{}) + getAllowedUserIDs func() map[string]struct{} +} + +// applyResolvedRuleToState emits firewall rules in the rule's directions and +// records authorized users into state according to the rule's protocol. The +// callbacks supply the auth-collection behaviour specific to the calling +// resolver (Account vs NetworkMapComponents). +func applyResolvedRuleToState( + rule *PolicyRule, + sourcePeers []*nbpeer.Peer, + destPeers []*nbpeer.Peer, + peerInSources bool, + peerInDestinations bool, + targetPeerSSHEnabled bool, + generateResources func(*PolicyRule, []*nbpeer.Peer, int), + cb ruleAuthCallbacks, + state *peerConnResolveState, +) { + emitRuleDirections(rule, sourcePeers, destPeers, peerInSources, peerInDestinations, generateResources) + + switch { + case rule.Protocol == PolicyRuleProtocolNetbirdSSH: + if !peerInDestinations { + return + } + state.sshEnabled = true + cb.collectSSHUsers(rule, state.authorizedUsers) + case rule.Protocol == PolicyRuleProtocolNetbirdVNC: + // VNC bidirectional rules grant access in both directions. + if !peerInDestinations && !(rule.Bidirectional && peerInSources) { + return + } + cb.collectVNCUsers(rule, state.vncAuthorizedUsers) + case policyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled: + if !peerInDestinations { + return + } + state.sshEnabled = true + state.authorizedUsers[auth.Wildcard] = cb.getAllowedUserIDs() + } +} + +// emitRuleDirections dispatches generateResources for each direction the rule +// applies in for the target peer. +func emitRuleDirections( + rule *PolicyRule, + sourcePeers []*nbpeer.Peer, + destPeers []*nbpeer.Peer, + peerInSources bool, + peerInDestinations bool, + generateResources func(*PolicyRule, []*nbpeer.Peer, int), +) { + if rule.Bidirectional { + if peerInSources { + generateResources(rule, destPeers, FirewallRuleDirectionIN) + } + if peerInDestinations { + generateResources(rule, sourcePeers, FirewallRuleDirectionOUT) + } + } + if peerInSources { + generateResources(rule, destPeers, FirewallRuleDirectionOUT) + } + if peerInDestinations { + generateResources(rule, sourcePeers, FirewallRuleDirectionIN) + } +} + +// mergeAuthorizedGroupUsers expands AuthorizedGroups (group ID to local user +// list) into target, mapping each local user to the set of user IDs in the +// referenced group. Used by both Account and NetworkMapComponents auth +// resolution paths. +func mergeAuthorizedGroupUsers( + ctx context.Context, + authorizedGroups map[string][]string, + groupIDToUserIDs map[string][]string, + target map[string]map[string]struct{}, +) { + for groupID, localUsers := range authorizedGroups { + userIDs, ok := groupIDToUserIDs[groupID] + if !ok { + log.WithContext(ctx).Tracef("no user IDs found for group ID %s", groupID) + continue + } + if len(localUsers) == 0 { + localUsers = []string{auth.Wildcard} + } + assignUsersToLocal(target, localUsers, userIDs) + } +} + +// assignUsersToLocal adds each userID to target[localUser] for every entry in +// localUsers, allocating the inner set on demand. +func assignUsersToLocal(target map[string]map[string]struct{}, localUsers, userIDs []string) { + for _, localUser := range localUsers { + if target[localUser] == nil { + target[localUser] = make(map[string]struct{}) + } + for _, userID := range userIDs { + target[localUser][userID] = struct{}{} + } + } +} + +// ensureWildcardUser ensures the wildcard local-user entry exists in target +// and adds the given authorized user to it. +func ensureWildcardUser(target map[string]map[string]struct{}, authorizedUser string) { + if target[auth.Wildcard] == nil { + target[auth.Wildcard] = make(map[string]struct{}) + } + target[auth.Wildcard][authorizedUser] = struct{}{} +} + +// normalizePolicyRuleProtocol maps NetBird virtual protocols (netbird-ssh, +// netbird-vnc) to TCP for the on-the-wire firewall view. For NetbirdVNC the +// rule is also scoped to the embedded VNC port so a VNC-only rule doesn't +// degrade into an unscoped TCP allow when the user left Ports empty. +// Returns the effective rule (possibly a shallow copy with Ports overridden) +// and the resulting protocol. +func normalizePolicyRuleProtocol(rule *PolicyRule) (*PolicyRule, PolicyRuleProtocolType) { + switch rule.Protocol { + case PolicyRuleProtocolNetbirdSSH: + return rule, PolicyRuleProtocolTCP + case PolicyRuleProtocolNetbirdVNC: + if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { + scoped := *rule + scoped.Ports = []string{strconv.Itoa(vncInternalPort)} + return &scoped, PolicyRuleProtocolTCP + } + return rule, PolicyRuleProtocolTCP + default: + return rule, rule.Protocol + } +} diff --git a/shared/auth/jwt/token_age.go b/shared/auth/jwt/token_age.go new file mode 100644 index 00000000000..a916256565f --- /dev/null +++ b/shared/auth/jwt/token_age.go @@ -0,0 +1,68 @@ +package jwt + +import ( + "errors" + "fmt" + "time" + + gojwt "github.com/golang-jwt/jwt/v5" +) + +// ErrTokenExpired signals that the iat-based token age check failed. Callers +// use errors.Is to branch on it when they want to surface a stable machine- +// readable reason (e.g. so a dashboard can prompt for re-login). +var ErrTokenExpired = errors.New("token expired") + +// CheckTokenAge validates that a JWT token's iat claim is within the given +// maxAge duration. Returns an error if the claims are unparsable, the iat +// claim is missing, or the token is too old. +func CheckTokenAge(token *gojwt.Token, maxAge time.Duration) error { + if token == nil { + return fmt.Errorf("token is nil") + } + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + return fmt.Errorf("token has invalid claims format (user=%s)", UserIDFromToken(token)) + } + + iat, ok := claims["iat"].(float64) + if !ok { + return fmt.Errorf("token missing iat claim (user=%s)", UserIDFromToken(token)) + } + + issuedAt := time.Unix(int64(iat), 0) + tokenAge := time.Since(issuedAt) + if tokenAge > maxAge { + return fmt.Errorf("%w for user=%s: age=%v, max=%v", ErrTokenExpired, userIDFromClaims(claims), tokenAge, maxAge) + } + + return nil +} + +// UserIDFromToken extracts a human-readable user identifier from a JWT token +// for use in error messages. Returns "unknown" if the token or claims are nil. +func UserIDFromToken(token *gojwt.Token) string { + if token == nil { + return "unknown" + } + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + return "unknown" + } + return userIDFromClaims(claims) +} + +// userIDFromClaims extracts a user identifier from JWT claims, trying sub, +// user_id, and email in order. +func userIDFromClaims(claims gojwt.MapClaims) string { + if sub, ok := claims["sub"].(string); ok && sub != "" { + return sub + } + if userID, ok := claims["user_id"].(string); ok && userID != "" { + return userID + } + if email, ok := claims["email"].(string); ok && email != "" { + return email + } + return "unknown" +} diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 58895b7c222..5b200e9d9b6 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -930,6 +930,7 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta { RosenpassEnabled: info.RosenpassEnabled, RosenpassPermissive: info.RosenpassPermissive, ServerSSHAllowed: info.ServerSSHAllowed, + ServerVNCAllowed: info.ServerVNCAllowed, DisableClientRoutes: info.DisableClientRoutes, DisableServerRoutes: info.DisableServerRoutes, @@ -940,6 +941,8 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta { DisableIPv6: info.DisableIPv6, LazyConnectionEnabled: info.LazyConnectionEnabled, + + DisableSSHAuth: info.DisableSSHAuth, }, Capabilities: peerCapabilities(*info), diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 942f3aa45cd..52fef507028 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -958,6 +958,10 @@ components: description: Indicates whether SSH access this peer is allowed or not type: boolean example: true + server_vnc_allowed: + description: Indicates whether the embedded VNC server is enabled on this peer + type: boolean + example: false disable_client_routes: description: Indicates whether client routes are disabled on this peer or not type: boolean diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index b3bb475a97d..cb4a4b440f0 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -511,6 +511,7 @@ func (e GroupMinimumIssued) Valid() bool { // Defines values for IdentityProviderType. const ( + IdentityProviderTypeAdfs IdentityProviderType = "adfs" IdentityProviderTypeEntra IdentityProviderType = "entra" IdentityProviderTypeGoogle IdentityProviderType = "google" IdentityProviderTypeMicrosoft IdentityProviderType = "microsoft" @@ -518,12 +519,13 @@ const ( IdentityProviderTypeOkta IdentityProviderType = "okta" IdentityProviderTypePocketid IdentityProviderType = "pocketid" IdentityProviderTypeZitadel IdentityProviderType = "zitadel" - IdentityProviderTypeAdfs IdentityProviderType = "adfs" ) // Valid indicates whether the value is a known member of the IdentityProviderType enum. func (e IdentityProviderType) Valid() bool { switch e { + case IdentityProviderTypeAdfs: + return true case IdentityProviderTypeEntra: return true case IdentityProviderTypeGoogle: @@ -538,8 +540,6 @@ func (e IdentityProviderType) Valid() bool { return true case IdentityProviderTypeZitadel: return true - case IdentityProviderTypeAdfs: - return true default: return false } @@ -1638,7 +1638,9 @@ type Checks struct { // OsVersionCheck Posture check for the version of operating system OsVersionCheck *OSVersionCheck `json:"os_version_check,omitempty"` - // PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128. + // PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it + // contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, + // so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128. PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:"peer_network_range_check,omitempty"` // ProcessCheck Posture Check for binaries exist and are running in the peer’s system @@ -3319,6 +3321,9 @@ type PeerLocalFlags struct { // ServerSshAllowed Indicates whether SSH access this peer is allowed or not ServerSshAllowed *bool `json:"server_ssh_allowed,omitempty"` + + // ServerVncAllowed Indicates whether the embedded VNC server is enabled on this peer + ServerVncAllowed *bool `json:"server_vnc_allowed,omitempty"` } // PeerMinimum defines model for PeerMinimum. @@ -3330,7 +3335,9 @@ type PeerMinimum struct { Name string `json:"name"` } -// PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128. +// PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it +// contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, +// so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128. type PeerNetworkRangeCheck struct { // Action Action to take upon policy match Action PeerNetworkRangeCheckAction `json:"action"` @@ -3785,15 +3792,15 @@ type ProxyAccessLogsResponse struct { // ProxyCluster A proxy cluster represents a group of proxy nodes serving the same address type ProxyCluster struct { - // Id Unique identifier of a proxy in this cluster - Id string `json:"id"` - // Address Cluster address used for CNAME targets Address string `json:"address"` // ConnectedProxies Number of proxy nodes connected in this cluster ConnectedProxies int `json:"connected_proxies"` + // Id Unique identifier of a proxy in this cluster + Id string `json:"id"` + // SelfHosted Whether this cluster is a self-hosted (BYOP) proxy managed by the account owner SelfHosted bool `json:"self_hosted"` } diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 13f4fbc8dd6..a720f9ec205 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -424,7 +424,7 @@ func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber { // Deprecated: Use DeviceAuthorizationFlowProvider.Descriptor instead. func (DeviceAuthorizationFlowProvider) EnumDescriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{31, 0} + return file_management_proto_rawDescGZIP(), []int{32, 0} } type EncryptedMessage struct { @@ -1255,6 +1255,7 @@ type Flags struct { EnableSSHRemotePortForwarding bool `protobuf:"varint,14,opt,name=enableSSHRemotePortForwarding,proto3" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth bool `protobuf:"varint,15,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` DisableIPv6 bool `protobuf:"varint,16,opt,name=disableIPv6,proto3" json:"disableIPv6,omitempty"` + ServerVNCAllowed bool `protobuf:"varint,18,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` } func (x *Flags) Reset() { @@ -1401,6 +1402,13 @@ func (x *Flags) GetDisableIPv6() bool { return false } +func (x *Flags) GetServerVNCAllowed() bool { + if x != nil { + return x.ServerVNCAllowed + } + return false +} + // PeerSystemMeta is machine meta data like OS and version. type PeerSystemMeta struct { state protoimpl.MessageState @@ -2421,6 +2429,8 @@ type NetworkMap struct { ForwardingRules []*ForwardingRule `protobuf:"bytes,12,rep,name=forwardingRules,proto3" json:"forwardingRules,omitempty"` // SSHAuth represents SSH authorization configuration SshAuth *SSHAuth `protobuf:"bytes,13,opt,name=sshAuth,proto3" json:"sshAuth,omitempty"` + // VNCAuth represents VNC authorization configuration + VncAuth *VNCAuth `protobuf:"bytes,14,opt,name=vncAuth,proto3" json:"vncAuth,omitempty"` } func (x *NetworkMap) Reset() { @@ -2546,6 +2556,13 @@ func (x *NetworkMap) GetSshAuth() *SSHAuth { return nil } +func (x *NetworkMap) GetVncAuth() *VNCAuth { + if x != nil { + return x.VncAuth + } + return nil +} + type SSHAuth struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2659,6 +2676,75 @@ func (x *MachineUserIndexes) GetIndexes() []uint32 { return nil } +// VNCAuth represents VNC authorization configuration for a peer. +type VNCAuth struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // UserIDClaim is the JWT claim to be used to get the users ID + UserIDClaim string `protobuf:"bytes,1,opt,name=UserIDClaim,proto3" json:"UserIDClaim,omitempty"` + // AuthorizedUsers is a list of hashed user IDs authorized to access this peer via VNC + AuthorizedUsers [][]byte `protobuf:"bytes,2,rep,name=AuthorizedUsers,proto3" json:"AuthorizedUsers,omitempty"` + // MachineUsers maps OS user names to their corresponding indexes in the AuthorizedUsers list. + // Used in session mode to determine which OS user to create the virtual session as. + // The wildcard "*" allows any OS user. + MachineUsers map[string]*MachineUserIndexes `protobuf:"bytes,3,rep,name=machine_users,json=machineUsers,proto3" json:"machine_users,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *VNCAuth) Reset() { + *x = VNCAuth{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *VNCAuth) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VNCAuth) ProtoMessage() {} + +func (x *VNCAuth) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[28] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VNCAuth.ProtoReflect.Descriptor instead. +func (*VNCAuth) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{28} +} + +func (x *VNCAuth) GetUserIDClaim() string { + if x != nil { + return x.UserIDClaim + } + return "" +} + +func (x *VNCAuth) GetAuthorizedUsers() [][]byte { + if x != nil { + return x.AuthorizedUsers + } + return nil +} + +func (x *VNCAuth) GetMachineUsers() map[string]*MachineUserIndexes { + if x != nil { + return x.MachineUsers + } + return nil +} + // RemotePeerConfig represents a configuration of a remote peer. // The properties are used to configure WireGuard Peers sections type RemotePeerConfig struct { @@ -2680,7 +2766,7 @@ type RemotePeerConfig struct { func (x *RemotePeerConfig) Reset() { *x = RemotePeerConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[28] + mi := &file_management_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2693,7 +2779,7 @@ func (x *RemotePeerConfig) String() string { func (*RemotePeerConfig) ProtoMessage() {} func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[28] + mi := &file_management_proto_msgTypes[29] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2706,7 +2792,7 @@ func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use RemotePeerConfig.ProtoReflect.Descriptor instead. func (*RemotePeerConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{28} + return file_management_proto_rawDescGZIP(), []int{29} } func (x *RemotePeerConfig) GetWgPubKey() string { @@ -2761,7 +2847,7 @@ type SSHConfig struct { func (x *SSHConfig) Reset() { *x = SSHConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[29] + mi := &file_management_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2774,7 +2860,7 @@ func (x *SSHConfig) String() string { func (*SSHConfig) ProtoMessage() {} func (x *SSHConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[29] + mi := &file_management_proto_msgTypes[30] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2787,7 +2873,7 @@ func (x *SSHConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHConfig.ProtoReflect.Descriptor instead. func (*SSHConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{29} + return file_management_proto_rawDescGZIP(), []int{30} } func (x *SSHConfig) GetSshEnabled() bool { @@ -2821,7 +2907,7 @@ type DeviceAuthorizationFlowRequest struct { func (x *DeviceAuthorizationFlowRequest) Reset() { *x = DeviceAuthorizationFlowRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[30] + mi := &file_management_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2834,7 +2920,7 @@ func (x *DeviceAuthorizationFlowRequest) String() string { func (*DeviceAuthorizationFlowRequest) ProtoMessage() {} func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[30] + mi := &file_management_proto_msgTypes[31] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2847,7 +2933,7 @@ func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{30} + return file_management_proto_rawDescGZIP(), []int{31} } // DeviceAuthorizationFlow represents Device Authorization Flow information @@ -2866,7 +2952,7 @@ type DeviceAuthorizationFlow struct { func (x *DeviceAuthorizationFlow) Reset() { *x = DeviceAuthorizationFlow{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[31] + mi := &file_management_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2879,7 +2965,7 @@ func (x *DeviceAuthorizationFlow) String() string { func (*DeviceAuthorizationFlow) ProtoMessage() {} func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[31] + mi := &file_management_proto_msgTypes[32] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2892,7 +2978,7 @@ func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlow.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{31} + return file_management_proto_rawDescGZIP(), []int{32} } func (x *DeviceAuthorizationFlow) GetProvider() DeviceAuthorizationFlowProvider { @@ -2919,7 +3005,7 @@ type PKCEAuthorizationFlowRequest struct { func (x *PKCEAuthorizationFlowRequest) Reset() { *x = PKCEAuthorizationFlowRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[32] + mi := &file_management_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2932,7 +3018,7 @@ func (x *PKCEAuthorizationFlowRequest) String() string { func (*PKCEAuthorizationFlowRequest) ProtoMessage() {} func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[32] + mi := &file_management_proto_msgTypes[33] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2945,7 +3031,7 @@ func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{32} + return file_management_proto_rawDescGZIP(), []int{33} } // PKCEAuthorizationFlow represents Authorization Code Flow information @@ -2962,7 +3048,7 @@ type PKCEAuthorizationFlow struct { func (x *PKCEAuthorizationFlow) Reset() { *x = PKCEAuthorizationFlow{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[33] + mi := &file_management_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2975,7 +3061,7 @@ func (x *PKCEAuthorizationFlow) String() string { func (*PKCEAuthorizationFlow) ProtoMessage() {} func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[33] + mi := &file_management_proto_msgTypes[34] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2988,7 +3074,7 @@ func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlow.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{33} + return file_management_proto_rawDescGZIP(), []int{34} } func (x *PKCEAuthorizationFlow) GetProviderConfig() *ProviderConfig { @@ -3036,7 +3122,7 @@ type ProviderConfig struct { func (x *ProviderConfig) Reset() { *x = ProviderConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[34] + mi := &file_management_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3049,7 +3135,7 @@ func (x *ProviderConfig) String() string { func (*ProviderConfig) ProtoMessage() {} func (x *ProviderConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[34] + mi := &file_management_proto_msgTypes[35] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3062,7 +3148,7 @@ func (x *ProviderConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderConfig.ProtoReflect.Descriptor instead. func (*ProviderConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{34} + return file_management_proto_rawDescGZIP(), []int{35} } func (x *ProviderConfig) GetClientID() string { @@ -3171,7 +3257,7 @@ type Route struct { func (x *Route) Reset() { *x = Route{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[35] + mi := &file_management_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3184,7 +3270,7 @@ func (x *Route) String() string { func (*Route) ProtoMessage() {} func (x *Route) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[35] + mi := &file_management_proto_msgTypes[36] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3197,7 +3283,7 @@ func (x *Route) ProtoReflect() protoreflect.Message { // Deprecated: Use Route.ProtoReflect.Descriptor instead. func (*Route) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{35} + return file_management_proto_rawDescGZIP(), []int{36} } func (x *Route) GetID() string { @@ -3286,7 +3372,7 @@ type DNSConfig struct { func (x *DNSConfig) Reset() { *x = DNSConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[36] + mi := &file_management_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3299,7 +3385,7 @@ func (x *DNSConfig) String() string { func (*DNSConfig) ProtoMessage() {} func (x *DNSConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[36] + mi := &file_management_proto_msgTypes[37] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3312,7 +3398,7 @@ func (x *DNSConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use DNSConfig.ProtoReflect.Descriptor instead. func (*DNSConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{36} + return file_management_proto_rawDescGZIP(), []int{37} } func (x *DNSConfig) GetServiceEnable() bool { @@ -3361,7 +3447,7 @@ type CustomZone struct { func (x *CustomZone) Reset() { *x = CustomZone{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[37] + mi := &file_management_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3374,7 +3460,7 @@ func (x *CustomZone) String() string { func (*CustomZone) ProtoMessage() {} func (x *CustomZone) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[37] + mi := &file_management_proto_msgTypes[38] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3387,7 +3473,7 @@ func (x *CustomZone) ProtoReflect() protoreflect.Message { // Deprecated: Use CustomZone.ProtoReflect.Descriptor instead. func (*CustomZone) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{37} + return file_management_proto_rawDescGZIP(), []int{38} } func (x *CustomZone) GetDomain() string { @@ -3434,7 +3520,7 @@ type SimpleRecord struct { func (x *SimpleRecord) Reset() { *x = SimpleRecord{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[38] + mi := &file_management_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3447,7 +3533,7 @@ func (x *SimpleRecord) String() string { func (*SimpleRecord) ProtoMessage() {} func (x *SimpleRecord) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[38] + mi := &file_management_proto_msgTypes[39] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3460,7 +3546,7 @@ func (x *SimpleRecord) ProtoReflect() protoreflect.Message { // Deprecated: Use SimpleRecord.ProtoReflect.Descriptor instead. func (*SimpleRecord) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{38} + return file_management_proto_rawDescGZIP(), []int{39} } func (x *SimpleRecord) GetName() string { @@ -3513,7 +3599,7 @@ type NameServerGroup struct { func (x *NameServerGroup) Reset() { *x = NameServerGroup{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[39] + mi := &file_management_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3526,7 +3612,7 @@ func (x *NameServerGroup) String() string { func (*NameServerGroup) ProtoMessage() {} func (x *NameServerGroup) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[39] + mi := &file_management_proto_msgTypes[40] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3539,7 +3625,7 @@ func (x *NameServerGroup) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServerGroup.ProtoReflect.Descriptor instead. func (*NameServerGroup) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{39} + return file_management_proto_rawDescGZIP(), []int{40} } func (x *NameServerGroup) GetNameServers() []*NameServer { @@ -3584,7 +3670,7 @@ type NameServer struct { func (x *NameServer) Reset() { *x = NameServer{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[40] + mi := &file_management_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3597,7 +3683,7 @@ func (x *NameServer) String() string { func (*NameServer) ProtoMessage() {} func (x *NameServer) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[40] + mi := &file_management_proto_msgTypes[41] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3610,7 +3696,7 @@ func (x *NameServer) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServer.ProtoReflect.Descriptor instead. func (*NameServer) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{40} + return file_management_proto_rawDescGZIP(), []int{41} } func (x *NameServer) GetIP() string { @@ -3661,7 +3747,7 @@ type FirewallRule struct { func (x *FirewallRule) Reset() { *x = FirewallRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[41] + mi := &file_management_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3674,7 +3760,7 @@ func (x *FirewallRule) String() string { func (*FirewallRule) ProtoMessage() {} func (x *FirewallRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[41] + mi := &file_management_proto_msgTypes[42] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3687,7 +3773,7 @@ func (x *FirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use FirewallRule.ProtoReflect.Descriptor instead. func (*FirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{41} + return file_management_proto_rawDescGZIP(), []int{42} } // Deprecated: Do not use. @@ -3766,7 +3852,7 @@ type NetworkAddress struct { func (x *NetworkAddress) Reset() { *x = NetworkAddress{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[42] + mi := &file_management_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3779,7 +3865,7 @@ func (x *NetworkAddress) String() string { func (*NetworkAddress) ProtoMessage() {} func (x *NetworkAddress) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[42] + mi := &file_management_proto_msgTypes[43] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3792,7 +3878,7 @@ func (x *NetworkAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkAddress.ProtoReflect.Descriptor instead. func (*NetworkAddress) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{42} + return file_management_proto_rawDescGZIP(), []int{43} } func (x *NetworkAddress) GetNetIP() string { @@ -3820,7 +3906,7 @@ type Checks struct { func (x *Checks) Reset() { *x = Checks{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[43] + mi := &file_management_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3833,7 +3919,7 @@ func (x *Checks) String() string { func (*Checks) ProtoMessage() {} func (x *Checks) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[43] + mi := &file_management_proto_msgTypes[44] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3846,7 +3932,7 @@ func (x *Checks) ProtoReflect() protoreflect.Message { // Deprecated: Use Checks.ProtoReflect.Descriptor instead. func (*Checks) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{43} + return file_management_proto_rawDescGZIP(), []int{44} } func (x *Checks) GetFiles() []string { @@ -3871,7 +3957,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[44] + mi := &file_management_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3884,7 +3970,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[44] + mi := &file_management_proto_msgTypes[45] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3897,7 +3983,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{44} + return file_management_proto_rawDescGZIP(), []int{45} } func (m *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -3968,7 +4054,7 @@ type RouteFirewallRule struct { func (x *RouteFirewallRule) Reset() { *x = RouteFirewallRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[45] + mi := &file_management_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3981,7 +4067,7 @@ func (x *RouteFirewallRule) String() string { func (*RouteFirewallRule) ProtoMessage() {} func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[45] + mi := &file_management_proto_msgTypes[46] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3994,7 +4080,7 @@ func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RouteFirewallRule.ProtoReflect.Descriptor instead. func (*RouteFirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{45} + return file_management_proto_rawDescGZIP(), []int{46} } func (x *RouteFirewallRule) GetSourceRanges() []string { @@ -4085,7 +4171,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[46] + mi := &file_management_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4098,7 +4184,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[46] + mi := &file_management_proto_msgTypes[47] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4111,7 +4197,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{46} + return file_management_proto_rawDescGZIP(), []int{47} } func (x *ForwardingRule) GetProtocol() RuleProtocol { @@ -4160,7 +4246,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[47] + mi := &file_management_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4173,7 +4259,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[47] + mi := &file_management_proto_msgTypes[48] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4186,7 +4272,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{47} + return file_management_proto_rawDescGZIP(), []int{48} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -4259,7 +4345,7 @@ type ExposeServiceResponse struct { func (x *ExposeServiceResponse) Reset() { *x = ExposeServiceResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[48] + mi := &file_management_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4272,7 +4358,7 @@ func (x *ExposeServiceResponse) String() string { func (*ExposeServiceResponse) ProtoMessage() {} func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[48] + mi := &file_management_proto_msgTypes[49] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4285,7 +4371,7 @@ func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceResponse.ProtoReflect.Descriptor instead. func (*ExposeServiceResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{48} + return file_management_proto_rawDescGZIP(), []int{49} } func (x *ExposeServiceResponse) GetServiceName() string { @@ -4327,7 +4413,7 @@ type RenewExposeRequest struct { func (x *RenewExposeRequest) Reset() { *x = RenewExposeRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[49] + mi := &file_management_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4340,7 +4426,7 @@ func (x *RenewExposeRequest) String() string { func (*RenewExposeRequest) ProtoMessage() {} func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[49] + mi := &file_management_proto_msgTypes[50] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4353,7 +4439,7 @@ func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeRequest.ProtoReflect.Descriptor instead. func (*RenewExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{49} + return file_management_proto_rawDescGZIP(), []int{50} } func (x *RenewExposeRequest) GetDomain() string { @@ -4372,7 +4458,7 @@ type RenewExposeResponse struct { func (x *RenewExposeResponse) Reset() { *x = RenewExposeResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[50] + mi := &file_management_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4385,7 +4471,7 @@ func (x *RenewExposeResponse) String() string { func (*RenewExposeResponse) ProtoMessage() {} func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[50] + mi := &file_management_proto_msgTypes[51] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4398,7 +4484,7 @@ func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeResponse.ProtoReflect.Descriptor instead. func (*RenewExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{50} + return file_management_proto_rawDescGZIP(), []int{51} } type StopExposeRequest struct { @@ -4412,7 +4498,7 @@ type StopExposeRequest struct { func (x *StopExposeRequest) Reset() { *x = StopExposeRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[51] + mi := &file_management_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4425,7 +4511,7 @@ func (x *StopExposeRequest) String() string { func (*StopExposeRequest) ProtoMessage() {} func (x *StopExposeRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[51] + mi := &file_management_proto_msgTypes[52] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4438,7 +4524,7 @@ func (x *StopExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeRequest.ProtoReflect.Descriptor instead. func (*StopExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{51} + return file_management_proto_rawDescGZIP(), []int{52} } func (x *StopExposeRequest) GetDomain() string { @@ -4457,7 +4543,7 @@ type StopExposeResponse struct { func (x *StopExposeResponse) Reset() { *x = StopExposeResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[52] + mi := &file_management_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4470,7 +4556,7 @@ func (x *StopExposeResponse) String() string { func (*StopExposeResponse) ProtoMessage() {} func (x *StopExposeResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[52] + mi := &file_management_proto_msgTypes[53] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4483,7 +4569,7 @@ func (x *StopExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeResponse.ProtoReflect.Descriptor instead. func (*StopExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{52} + return file_management_proto_rawDescGZIP(), []int{53} } type PortInfo_Range struct { @@ -4498,7 +4584,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[54] + mi := &file_management_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4511,7 +4597,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[54] + mi := &file_management_proto_msgTypes[56] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4524,7 +4610,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead. func (*PortInfo_Range) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{44, 0} + return file_management_proto_rawDescGZIP(), []int{45, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -4643,7 +4729,7 @@ var file_management_proto_rawDesc = []byte{ 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, - 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xe1, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, + 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x8d, 0x06, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, @@ -4689,223 +4775,248 @@ var file_management_proto_rawDesc = []byte{ 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, - 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x22, 0xb2, 0x05, 0x0a, 0x0e, 0x50, - 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, - 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, - 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, - 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, - 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, - 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, - 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, - 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, - 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, - 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, - 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, - 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, - 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, - 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, - 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, - 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, - 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, - 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, - 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, - 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, - 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, - 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, - 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, - 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, - 0xb4, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, - 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, - 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, - 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, - 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, - 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, - 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, - 0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, - 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, - 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, - 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, - 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, - 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, - 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, - 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, - 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, - 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, - 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, - 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, - 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, - 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, - 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, - 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, - 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, - 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, - 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, - 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, - 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, - 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, - 0x0a, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, - 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x56, 0x4e, 0x43, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x12, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x56, 0x4e, 0x43, 0x41, + 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x22, 0xb2, 0x05, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, + 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, + 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, + 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, + 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, + 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, + 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, + 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, + 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, + 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, + 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, + 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, + 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, + 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, + 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, + 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, + 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, + 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, + 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, + 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, + 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, + 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, + 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, + 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, + 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, + 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, + 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, + 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0xb4, 0x01, 0x0a, 0x0d, + 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, + 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, + 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70, + 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, + 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a, + 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, + 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, - 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, - 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, - 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, - 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, - 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, - 0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, - 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, - 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, - 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, - 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, - 0x75, 0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, - 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, - 0x22, 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, - 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, - 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, - 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, - 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, - 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, - 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, - 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, - 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, - 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, - 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, - 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, - 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, - 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, - 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, - 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, - 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, - 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, - 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, - 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, + 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a, + 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a, + 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, + 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, + 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, + 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, + 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, + 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, + 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, + 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, + 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, + 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, + 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, + 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, 0x73, + 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, + 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, + 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x61, + 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74, + 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, 0x73, + 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, 0x72, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, + 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, + 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, 0x1f, 0x52, + 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, + 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, + 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6d, + 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, 0x0a, + 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, + 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, + 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22, 0x52, 0x0a, 0x12, + 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, + 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, + 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x22, 0x97, 0x06, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, + 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, + 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, + 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, + 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, 0x4e, + 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, + 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, + 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, + 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, + 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, + 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, + 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, + 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, + 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, + 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x07, + 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, + 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x12, 0x2d, 0x0a, 0x07, 0x76, + 0x6e, 0x63, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, + 0x68, 0x52, 0x07, 0x76, 0x6e, 0x63, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53, + 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, + 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, + 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, + 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, + 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, + 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, + 0x82, 0x02, 0x0a, 0x07, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, @@ -4914,10 +5025,7 @@ var file_management_proto_rawDesc = []byte{ 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, - 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, - 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, - 0x65, 0x78, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, + 0x3a, 0x02, 0x38, 0x01, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, @@ -5267,7 +5375,7 @@ func file_management_proto_rawDescGZIP() []byte { } var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 55) +var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 57) var file_management_proto_goTypes = []interface{}{ (JobStatus)(0), // 0: management.JobStatus (PeerCapability)(0), // 1: management.PeerCapability @@ -5305,35 +5413,37 @@ var file_management_proto_goTypes = []interface{}{ (*NetworkMap)(nil), // 33: management.NetworkMap (*SSHAuth)(nil), // 34: management.SSHAuth (*MachineUserIndexes)(nil), // 35: management.MachineUserIndexes - (*RemotePeerConfig)(nil), // 36: management.RemotePeerConfig - (*SSHConfig)(nil), // 37: management.SSHConfig - (*DeviceAuthorizationFlowRequest)(nil), // 38: management.DeviceAuthorizationFlowRequest - (*DeviceAuthorizationFlow)(nil), // 39: management.DeviceAuthorizationFlow - (*PKCEAuthorizationFlowRequest)(nil), // 40: management.PKCEAuthorizationFlowRequest - (*PKCEAuthorizationFlow)(nil), // 41: management.PKCEAuthorizationFlow - (*ProviderConfig)(nil), // 42: management.ProviderConfig - (*Route)(nil), // 43: management.Route - (*DNSConfig)(nil), // 44: management.DNSConfig - (*CustomZone)(nil), // 45: management.CustomZone - (*SimpleRecord)(nil), // 46: management.SimpleRecord - (*NameServerGroup)(nil), // 47: management.NameServerGroup - (*NameServer)(nil), // 48: management.NameServer - (*FirewallRule)(nil), // 49: management.FirewallRule - (*NetworkAddress)(nil), // 50: management.NetworkAddress - (*Checks)(nil), // 51: management.Checks - (*PortInfo)(nil), // 52: management.PortInfo - (*RouteFirewallRule)(nil), // 53: management.RouteFirewallRule - (*ForwardingRule)(nil), // 54: management.ForwardingRule - (*ExposeServiceRequest)(nil), // 55: management.ExposeServiceRequest - (*ExposeServiceResponse)(nil), // 56: management.ExposeServiceResponse - (*RenewExposeRequest)(nil), // 57: management.RenewExposeRequest - (*RenewExposeResponse)(nil), // 58: management.RenewExposeResponse - (*StopExposeRequest)(nil), // 59: management.StopExposeRequest - (*StopExposeResponse)(nil), // 60: management.StopExposeResponse - nil, // 61: management.SSHAuth.MachineUsersEntry - (*PortInfo_Range)(nil), // 62: management.PortInfo.Range - (*timestamppb.Timestamp)(nil), // 63: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 64: google.protobuf.Duration + (*VNCAuth)(nil), // 36: management.VNCAuth + (*RemotePeerConfig)(nil), // 37: management.RemotePeerConfig + (*SSHConfig)(nil), // 38: management.SSHConfig + (*DeviceAuthorizationFlowRequest)(nil), // 39: management.DeviceAuthorizationFlowRequest + (*DeviceAuthorizationFlow)(nil), // 40: management.DeviceAuthorizationFlow + (*PKCEAuthorizationFlowRequest)(nil), // 41: management.PKCEAuthorizationFlowRequest + (*PKCEAuthorizationFlow)(nil), // 42: management.PKCEAuthorizationFlow + (*ProviderConfig)(nil), // 43: management.ProviderConfig + (*Route)(nil), // 44: management.Route + (*DNSConfig)(nil), // 45: management.DNSConfig + (*CustomZone)(nil), // 46: management.CustomZone + (*SimpleRecord)(nil), // 47: management.SimpleRecord + (*NameServerGroup)(nil), // 48: management.NameServerGroup + (*NameServer)(nil), // 49: management.NameServer + (*FirewallRule)(nil), // 50: management.FirewallRule + (*NetworkAddress)(nil), // 51: management.NetworkAddress + (*Checks)(nil), // 52: management.Checks + (*PortInfo)(nil), // 53: management.PortInfo + (*RouteFirewallRule)(nil), // 54: management.RouteFirewallRule + (*ForwardingRule)(nil), // 55: management.ForwardingRule + (*ExposeServiceRequest)(nil), // 56: management.ExposeServiceRequest + (*ExposeServiceResponse)(nil), // 57: management.ExposeServiceResponse + (*RenewExposeRequest)(nil), // 58: management.RenewExposeRequest + (*RenewExposeResponse)(nil), // 59: management.RenewExposeResponse + (*StopExposeRequest)(nil), // 60: management.StopExposeRequest + (*StopExposeResponse)(nil), // 61: management.StopExposeResponse + nil, // 62: management.SSHAuth.MachineUsersEntry + nil, // 63: management.VNCAuth.MachineUsersEntry + (*PortInfo_Range)(nil), // 64: management.PortInfo.Range + (*timestamppb.Timestamp)(nil), // 65: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 66: google.protobuf.Duration } var file_management_proto_depIdxs = []int32{ 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters @@ -5342,92 +5452,95 @@ var file_management_proto_depIdxs = []int32{ 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta 25, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig 31, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig - 36, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig + 37, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig 33, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap - 51, // 8: management.SyncResponse.Checks:type_name -> management.Checks + 52, // 8: management.SyncResponse.Checks:type_name -> management.Checks 21, // 9: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta 21, // 10: management.LoginRequest.meta:type_name -> management.PeerSystemMeta 17, // 11: management.LoginRequest.peerKeys:type_name -> management.PeerKeys - 50, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress + 51, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress 18, // 13: management.PeerSystemMeta.environment:type_name -> management.Environment 19, // 14: management.PeerSystemMeta.files:type_name -> management.File 20, // 15: management.PeerSystemMeta.flags:type_name -> management.Flags 1, // 16: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability 25, // 17: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig 31, // 18: management.LoginResponse.peerConfig:type_name -> management.PeerConfig - 51, // 19: management.LoginResponse.Checks:type_name -> management.Checks - 63, // 20: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp + 52, // 19: management.LoginResponse.Checks:type_name -> management.Checks + 65, // 20: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp 26, // 21: management.NetbirdConfig.stuns:type_name -> management.HostConfig 30, // 22: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig 26, // 23: management.NetbirdConfig.signal:type_name -> management.HostConfig 27, // 24: management.NetbirdConfig.relay:type_name -> management.RelayConfig 28, // 25: management.NetbirdConfig.flow:type_name -> management.FlowConfig 6, // 26: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol - 64, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration + 66, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration 26, // 28: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig - 37, // 29: management.PeerConfig.sshConfig:type_name -> management.SSHConfig + 38, // 29: management.PeerConfig.sshConfig:type_name -> management.SSHConfig 32, // 30: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings 31, // 31: management.NetworkMap.peerConfig:type_name -> management.PeerConfig - 36, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig - 43, // 33: management.NetworkMap.Routes:type_name -> management.Route - 44, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig - 36, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig - 49, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule - 53, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule - 54, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule + 37, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig + 44, // 33: management.NetworkMap.Routes:type_name -> management.Route + 45, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig + 37, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig + 50, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule + 54, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule + 55, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule 34, // 39: management.NetworkMap.sshAuth:type_name -> management.SSHAuth - 61, // 40: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry - 37, // 41: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig - 29, // 42: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig - 7, // 43: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider - 42, // 44: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 42, // 45: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 47, // 46: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup - 45, // 47: management.DNSConfig.CustomZones:type_name -> management.CustomZone - 46, // 48: management.CustomZone.Records:type_name -> management.SimpleRecord - 48, // 49: management.NameServerGroup.NameServers:type_name -> management.NameServer - 3, // 50: management.FirewallRule.Direction:type_name -> management.RuleDirection - 4, // 51: management.FirewallRule.Action:type_name -> management.RuleAction - 2, // 52: management.FirewallRule.Protocol:type_name -> management.RuleProtocol - 52, // 53: management.FirewallRule.PortInfo:type_name -> management.PortInfo - 62, // 54: management.PortInfo.range:type_name -> management.PortInfo.Range - 4, // 55: management.RouteFirewallRule.action:type_name -> management.RuleAction - 2, // 56: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol - 52, // 57: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo - 2, // 58: management.ForwardingRule.protocol:type_name -> management.RuleProtocol - 52, // 59: management.ForwardingRule.destinationPort:type_name -> management.PortInfo - 52, // 60: management.ForwardingRule.translatedPort:type_name -> management.PortInfo - 5, // 61: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol - 35, // 62: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 8, // 63: management.ManagementService.Login:input_type -> management.EncryptedMessage - 8, // 64: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 24, // 65: management.ManagementService.GetServerKey:input_type -> management.Empty - 24, // 66: management.ManagementService.isHealthy:input_type -> management.Empty - 8, // 67: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 68: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 69: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 8, // 70: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 8, // 71: management.ManagementService.Job:input_type -> management.EncryptedMessage - 8, // 72: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 8, // 73: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 8, // 74: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 8, // 75: management.ManagementService.Login:output_type -> management.EncryptedMessage - 8, // 76: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 23, // 77: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 24, // 78: management.ManagementService.isHealthy:output_type -> management.Empty - 8, // 79: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 8, // 80: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 24, // 81: management.ManagementService.SyncMeta:output_type -> management.Empty - 24, // 82: management.ManagementService.Logout:output_type -> management.Empty - 8, // 83: management.ManagementService.Job:output_type -> management.EncryptedMessage - 8, // 84: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 8, // 85: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 8, // 86: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 75, // [75:87] is the sub-list for method output_type - 63, // [63:75] is the sub-list for method input_type - 63, // [63:63] is the sub-list for extension type_name - 63, // [63:63] is the sub-list for extension extendee - 0, // [0:63] is the sub-list for field type_name + 36, // 40: management.NetworkMap.vncAuth:type_name -> management.VNCAuth + 62, // 41: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry + 63, // 42: management.VNCAuth.machine_users:type_name -> management.VNCAuth.MachineUsersEntry + 38, // 43: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig + 29, // 44: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig + 7, // 45: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider + 43, // 46: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 43, // 47: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 48, // 48: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup + 46, // 49: management.DNSConfig.CustomZones:type_name -> management.CustomZone + 47, // 50: management.CustomZone.Records:type_name -> management.SimpleRecord + 49, // 51: management.NameServerGroup.NameServers:type_name -> management.NameServer + 3, // 52: management.FirewallRule.Direction:type_name -> management.RuleDirection + 4, // 53: management.FirewallRule.Action:type_name -> management.RuleAction + 2, // 54: management.FirewallRule.Protocol:type_name -> management.RuleProtocol + 53, // 55: management.FirewallRule.PortInfo:type_name -> management.PortInfo + 64, // 56: management.PortInfo.range:type_name -> management.PortInfo.Range + 4, // 57: management.RouteFirewallRule.action:type_name -> management.RuleAction + 2, // 58: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol + 53, // 59: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo + 2, // 60: management.ForwardingRule.protocol:type_name -> management.RuleProtocol + 53, // 61: management.ForwardingRule.destinationPort:type_name -> management.PortInfo + 53, // 62: management.ForwardingRule.translatedPort:type_name -> management.PortInfo + 5, // 63: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol + 35, // 64: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 35, // 65: management.VNCAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 8, // 66: management.ManagementService.Login:input_type -> management.EncryptedMessage + 8, // 67: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 24, // 68: management.ManagementService.GetServerKey:input_type -> management.Empty + 24, // 69: management.ManagementService.isHealthy:input_type -> management.Empty + 8, // 70: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 71: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 72: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 8, // 73: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 8, // 74: management.ManagementService.Job:input_type -> management.EncryptedMessage + 8, // 75: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 8, // 76: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 8, // 77: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 8, // 78: management.ManagementService.Login:output_type -> management.EncryptedMessage + 8, // 79: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 23, // 80: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 24, // 81: management.ManagementService.isHealthy:output_type -> management.Empty + 8, // 82: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 8, // 83: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 24, // 84: management.ManagementService.SyncMeta:output_type -> management.Empty + 24, // 85: management.ManagementService.Logout:output_type -> management.Empty + 8, // 86: management.ManagementService.Job:output_type -> management.EncryptedMessage + 8, // 87: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 8, // 88: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 8, // 89: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 78, // [78:90] is the sub-list for method output_type + 66, // [66:78] is the sub-list for method input_type + 66, // [66:66] is the sub-list for extension type_name + 66, // [66:66] is the sub-list for extension extendee + 0, // [0:66] is the sub-list for field type_name } func init() { file_management_proto_init() } @@ -5773,7 +5886,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RemotePeerConfig); i { + switch v := v.(*VNCAuth); i { case 0: return &v.state case 1: @@ -5785,7 +5898,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SSHConfig); i { + switch v := v.(*RemotePeerConfig); i { case 0: return &v.state case 1: @@ -5797,7 +5910,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlowRequest); i { + switch v := v.(*SSHConfig); i { case 0: return &v.state case 1: @@ -5809,7 +5922,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlow); i { + switch v := v.(*DeviceAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -5821,7 +5934,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlowRequest); i { + switch v := v.(*DeviceAuthorizationFlow); i { case 0: return &v.state case 1: @@ -5833,7 +5946,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlow); i { + switch v := v.(*PKCEAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -5845,7 +5958,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProviderConfig); i { + switch v := v.(*PKCEAuthorizationFlow); i { case 0: return &v.state case 1: @@ -5857,7 +5970,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Route); i { + switch v := v.(*ProviderConfig); i { case 0: return &v.state case 1: @@ -5869,7 +5982,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DNSConfig); i { + switch v := v.(*Route); i { case 0: return &v.state case 1: @@ -5881,7 +5994,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CustomZone); i { + switch v := v.(*DNSConfig); i { case 0: return &v.state case 1: @@ -5893,7 +6006,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SimpleRecord); i { + switch v := v.(*CustomZone); i { case 0: return &v.state case 1: @@ -5905,7 +6018,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServerGroup); i { + switch v := v.(*SimpleRecord); i { case 0: return &v.state case 1: @@ -5917,7 +6030,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServer); i { + switch v := v.(*NameServerGroup); i { case 0: return &v.state case 1: @@ -5929,7 +6042,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FirewallRule); i { + switch v := v.(*NameServer); i { case 0: return &v.state case 1: @@ -5941,7 +6054,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkAddress); i { + switch v := v.(*FirewallRule); i { case 0: return &v.state case 1: @@ -5953,7 +6066,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Checks); i { + switch v := v.(*NetworkAddress); i { case 0: return &v.state case 1: @@ -5965,7 +6078,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PortInfo); i { + switch v := v.(*Checks); i { case 0: return &v.state case 1: @@ -5977,7 +6090,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RouteFirewallRule); i { + switch v := v.(*PortInfo); i { case 0: return &v.state case 1: @@ -5989,7 +6102,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ForwardingRule); i { + switch v := v.(*RouteFirewallRule); i { case 0: return &v.state case 1: @@ -6001,7 +6114,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceRequest); i { + switch v := v.(*ForwardingRule); i { case 0: return &v.state case 1: @@ -6013,7 +6126,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceResponse); i { + switch v := v.(*ExposeServiceRequest); i { case 0: return &v.state case 1: @@ -6025,7 +6138,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeRequest); i { + switch v := v.(*ExposeServiceResponse); i { case 0: return &v.state case 1: @@ -6037,7 +6150,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[50].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeResponse); i { + switch v := v.(*RenewExposeRequest); i { case 0: return &v.state case 1: @@ -6049,7 +6162,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[51].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopExposeRequest); i { + switch v := v.(*RenewExposeResponse); i { case 0: return &v.state case 1: @@ -6061,6 +6174,18 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[52].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StopExposeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[53].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StopExposeResponse); i { case 0: return &v.state @@ -6072,7 +6197,7 @@ func file_management_proto_init() { return nil } } - file_management_proto_msgTypes[54].Exporter = func(v interface{}, i int) interface{} { + file_management_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*PortInfo_Range); i { case 0: return &v.state @@ -6091,7 +6216,7 @@ func file_management_proto_init() { file_management_proto_msgTypes[2].OneofWrappers = []interface{}{ (*JobResponse_Bundle)(nil), } - file_management_proto_msgTypes[44].OneofWrappers = []interface{}{ + file_management_proto_msgTypes[45].OneofWrappers = []interface{}{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } @@ -6101,7 +6226,7 @@ func file_management_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_management_proto_rawDesc, NumEnums: 8, - NumMessages: 55, + NumMessages: 57, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 461a614fe6a..e610312f9d2 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -202,6 +202,8 @@ message Flags { bool disableSSHAuth = 15; bool disableIPv6 = 16; + + bool serverVNCAllowed = 18; } // PeerCapability represents a feature the client binary supports. @@ -404,6 +406,9 @@ message NetworkMap { // SSHAuth represents SSH authorization configuration SSHAuth sshAuth = 13; + + // VNCAuth represents VNC authorization configuration + VNCAuth vncAuth = 14; } message SSHAuth { @@ -421,6 +426,20 @@ message MachineUserIndexes { repeated uint32 indexes = 1; } +// VNCAuth represents VNC authorization configuration for a peer. +message VNCAuth { + // UserIDClaim is the JWT claim to be used to get the users ID + string UserIDClaim = 1; + + // AuthorizedUsers is a list of hashed user IDs authorized to access this peer via VNC + repeated bytes AuthorizedUsers = 2; + + // MachineUsers maps OS user names to their corresponding indexes in the AuthorizedUsers list. + // Used in session mode to determine which OS user to create the virtual session as. + // The wildcard "*" allows any OS user. + map machine_users = 3; +} + // RemotePeerConfig represents a configuration of a remote peer. // The properties are used to configure WireGuard Peers sections message RemotePeerConfig { diff --git a/shared/management/proto/management_grpc.pb.go b/shared/management/proto/management_grpc.pb.go index 39a34204115..42b23519d26 100644 --- a/shared/management/proto/management_grpc.pb.go +++ b/shared/management/proto/management_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v7.34.1 +// source: management.proto package proto @@ -11,8 +15,23 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ManagementService_Login_FullMethodName = "/management.ManagementService/Login" + ManagementService_Sync_FullMethodName = "/management.ManagementService/Sync" + ManagementService_GetServerKey_FullMethodName = "/management.ManagementService/GetServerKey" + ManagementService_IsHealthy_FullMethodName = "/management.ManagementService/isHealthy" + ManagementService_GetDeviceAuthorizationFlow_FullMethodName = "/management.ManagementService/GetDeviceAuthorizationFlow" + ManagementService_GetPKCEAuthorizationFlow_FullMethodName = "/management.ManagementService/GetPKCEAuthorizationFlow" + ManagementService_SyncMeta_FullMethodName = "/management.ManagementService/SyncMeta" + ManagementService_Logout_FullMethodName = "/management.ManagementService/Logout" + ManagementService_Job_FullMethodName = "/management.ManagementService/Job" + ManagementService_CreateExpose_FullMethodName = "/management.ManagementService/CreateExpose" + ManagementService_RenewExpose_FullMethodName = "/management.ManagementService/RenewExpose" + ManagementService_StopExpose_FullMethodName = "/management.ManagementService/StopExpose" +) // ManagementServiceClient is the client API for ManagementService service. // @@ -25,7 +44,7 @@ type ManagementServiceClient interface { // For example, if a new peer has been added to an account all other connected peers will receive this peer's Wireguard public key as an update // The initial SyncResponse contains all of the available peers so the local state can be refreshed // Returns encrypted SyncResponse in EncryptedMessage.Body - Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (ManagementService_SyncClient, error) + Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (grpc.ServerStreamingClient[EncryptedMessage], error) // Exposes a Wireguard public key of the Management service. // This key is used to support message encryption between client and server GetServerKey(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ServerKeyResponse, error) @@ -51,7 +70,7 @@ type ManagementServiceClient interface { // Logout logs out the peer and removes it from the management server Logout(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error) // Executes a job on a target peer (e.g., debug bundle) - Job(ctx context.Context, opts ...grpc.CallOption) (ManagementService_JobClient, error) + Job(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage], error) // CreateExpose creates a temporary reverse proxy service for a peer CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) // RenewExpose extends the TTL of an active expose session @@ -69,20 +88,22 @@ func NewManagementServiceClient(cc grpc.ClientConnInterface) ManagementServiceCl } func (c *managementServiceClient) Login(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, "/management.ManagementService/Login", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_Login_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *managementServiceClient) Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (ManagementService_SyncClient, error) { - stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[0], "/management.ManagementService/Sync", opts...) +func (c *managementServiceClient) Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (grpc.ServerStreamingClient[EncryptedMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[0], ManagementService_Sync_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &managementServiceSyncClient{stream} + x := &grpc.GenericClientStream[EncryptedMessage, EncryptedMessage]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -92,26 +113,13 @@ func (c *managementServiceClient) Sync(ctx context.Context, in *EncryptedMessage return x, nil } -type ManagementService_SyncClient interface { - Recv() (*EncryptedMessage, error) - grpc.ClientStream -} - -type managementServiceSyncClient struct { - grpc.ClientStream -} - -func (x *managementServiceSyncClient) Recv() (*EncryptedMessage, error) { - m := new(EncryptedMessage) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ManagementService_SyncClient = grpc.ServerStreamingClient[EncryptedMessage] func (c *managementServiceClient) GetServerKey(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ServerKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ServerKeyResponse) - err := c.cc.Invoke(ctx, "/management.ManagementService/GetServerKey", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_GetServerKey_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -119,8 +127,9 @@ func (c *managementServiceClient) GetServerKey(ctx context.Context, in *Empty, o } func (c *managementServiceClient) IsHealthy(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) - err := c.cc.Invoke(ctx, "/management.ManagementService/isHealthy", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_IsHealthy_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -128,8 +137,9 @@ func (c *managementServiceClient) IsHealthy(ctx context.Context, in *Empty, opts } func (c *managementServiceClient) GetDeviceAuthorizationFlow(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, "/management.ManagementService/GetDeviceAuthorizationFlow", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_GetDeviceAuthorizationFlow_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -137,8 +147,9 @@ func (c *managementServiceClient) GetDeviceAuthorizationFlow(ctx context.Context } func (c *managementServiceClient) GetPKCEAuthorizationFlow(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, "/management.ManagementService/GetPKCEAuthorizationFlow", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_GetPKCEAuthorizationFlow_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -146,8 +157,9 @@ func (c *managementServiceClient) GetPKCEAuthorizationFlow(ctx context.Context, } func (c *managementServiceClient) SyncMeta(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) - err := c.cc.Invoke(ctx, "/management.ManagementService/SyncMeta", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_SyncMeta_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -155,48 +167,32 @@ func (c *managementServiceClient) SyncMeta(ctx context.Context, in *EncryptedMes } func (c *managementServiceClient) Logout(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) - err := c.cc.Invoke(ctx, "/management.ManagementService/Logout", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_Logout_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *managementServiceClient) Job(ctx context.Context, opts ...grpc.CallOption) (ManagementService_JobClient, error) { - stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[1], "/management.ManagementService/Job", opts...) +func (c *managementServiceClient) Job(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[1], ManagementService_Job_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &managementServiceJobClient{stream} + x := &grpc.GenericClientStream[EncryptedMessage, EncryptedMessage]{ClientStream: stream} return x, nil } -type ManagementService_JobClient interface { - Send(*EncryptedMessage) error - Recv() (*EncryptedMessage, error) - grpc.ClientStream -} - -type managementServiceJobClient struct { - grpc.ClientStream -} - -func (x *managementServiceJobClient) Send(m *EncryptedMessage) error { - return x.ClientStream.SendMsg(m) -} - -func (x *managementServiceJobClient) Recv() (*EncryptedMessage, error) { - m := new(EncryptedMessage) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ManagementService_JobClient = grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage] func (c *managementServiceClient) CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, "/management.ManagementService/CreateExpose", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_CreateExpose_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -204,8 +200,9 @@ func (c *managementServiceClient) CreateExpose(ctx context.Context, in *Encrypte } func (c *managementServiceClient) RenewExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, "/management.ManagementService/RenewExpose", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_RenewExpose_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -213,8 +210,9 @@ func (c *managementServiceClient) RenewExpose(ctx context.Context, in *Encrypted } func (c *managementServiceClient) StopExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, "/management.ManagementService/StopExpose", in, out, opts...) + err := c.cc.Invoke(ctx, ManagementService_StopExpose_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -223,7 +221,7 @@ func (c *managementServiceClient) StopExpose(ctx context.Context, in *EncryptedM // ManagementServiceServer is the server API for ManagementService service. // All implementations must embed UnimplementedManagementServiceServer -// for forward compatibility +// for forward compatibility. type ManagementServiceServer interface { // Login logs in peer. In case server returns codes.PermissionDenied this endpoint can be used to register Peer providing LoginRequest.setupKey // Returns encrypted LoginResponse in EncryptedMessage.Body @@ -232,7 +230,7 @@ type ManagementServiceServer interface { // For example, if a new peer has been added to an account all other connected peers will receive this peer's Wireguard public key as an update // The initial SyncResponse contains all of the available peers so the local state can be refreshed // Returns encrypted SyncResponse in EncryptedMessage.Body - Sync(*EncryptedMessage, ManagementService_SyncServer) error + Sync(*EncryptedMessage, grpc.ServerStreamingServer[EncryptedMessage]) error // Exposes a Wireguard public key of the Management service. // This key is used to support message encryption between client and server GetServerKey(context.Context, *Empty) (*ServerKeyResponse, error) @@ -258,7 +256,7 @@ type ManagementServiceServer interface { // Logout logs out the peer and removes it from the management server Logout(context.Context, *EncryptedMessage) (*Empty, error) // Executes a job on a target peer (e.g., debug bundle) - Job(ManagementService_JobServer) error + Job(grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage]) error // CreateExpose creates a temporary reverse proxy service for a peer CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) // RenewExpose extends the TTL of an active expose session @@ -268,47 +266,51 @@ type ManagementServiceServer interface { mustEmbedUnimplementedManagementServiceServer() } -// UnimplementedManagementServiceServer must be embedded to have forward compatible implementations. -type UnimplementedManagementServiceServer struct { -} +// UnimplementedManagementServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedManagementServiceServer struct{} func (UnimplementedManagementServiceServer) Login(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") + return nil, status.Error(codes.Unimplemented, "method Login not implemented") } -func (UnimplementedManagementServiceServer) Sync(*EncryptedMessage, ManagementService_SyncServer) error { - return status.Errorf(codes.Unimplemented, "method Sync not implemented") +func (UnimplementedManagementServiceServer) Sync(*EncryptedMessage, grpc.ServerStreamingServer[EncryptedMessage]) error { + return status.Error(codes.Unimplemented, "method Sync not implemented") } func (UnimplementedManagementServiceServer) GetServerKey(context.Context, *Empty) (*ServerKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetServerKey not implemented") + return nil, status.Error(codes.Unimplemented, "method GetServerKey not implemented") } func (UnimplementedManagementServiceServer) IsHealthy(context.Context, *Empty) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method IsHealthy not implemented") + return nil, status.Error(codes.Unimplemented, "method IsHealthy not implemented") } func (UnimplementedManagementServiceServer) GetDeviceAuthorizationFlow(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetDeviceAuthorizationFlow not implemented") + return nil, status.Error(codes.Unimplemented, "method GetDeviceAuthorizationFlow not implemented") } func (UnimplementedManagementServiceServer) GetPKCEAuthorizationFlow(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetPKCEAuthorizationFlow not implemented") + return nil, status.Error(codes.Unimplemented, "method GetPKCEAuthorizationFlow not implemented") } func (UnimplementedManagementServiceServer) SyncMeta(context.Context, *EncryptedMessage) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method SyncMeta not implemented") + return nil, status.Error(codes.Unimplemented, "method SyncMeta not implemented") } func (UnimplementedManagementServiceServer) Logout(context.Context, *EncryptedMessage) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented") + return nil, status.Error(codes.Unimplemented, "method Logout not implemented") } -func (UnimplementedManagementServiceServer) Job(ManagementService_JobServer) error { - return status.Errorf(codes.Unimplemented, "method Job not implemented") +func (UnimplementedManagementServiceServer) Job(grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage]) error { + return status.Error(codes.Unimplemented, "method Job not implemented") } func (UnimplementedManagementServiceServer) CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Errorf(codes.Unimplemented, "method CreateExpose not implemented") + return nil, status.Error(codes.Unimplemented, "method CreateExpose not implemented") } func (UnimplementedManagementServiceServer) RenewExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Errorf(codes.Unimplemented, "method RenewExpose not implemented") + return nil, status.Error(codes.Unimplemented, "method RenewExpose not implemented") } func (UnimplementedManagementServiceServer) StopExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Errorf(codes.Unimplemented, "method StopExpose not implemented") + return nil, status.Error(codes.Unimplemented, "method StopExpose not implemented") } func (UnimplementedManagementServiceServer) mustEmbedUnimplementedManagementServiceServer() {} +func (UnimplementedManagementServiceServer) testEmbeddedByValue() {} // UnsafeManagementServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ManagementServiceServer will @@ -318,6 +320,13 @@ type UnsafeManagementServiceServer interface { } func RegisterManagementServiceServer(s grpc.ServiceRegistrar, srv ManagementServiceServer) { + // If the following call panics, it indicates UnimplementedManagementServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&ManagementService_ServiceDesc, srv) } @@ -331,7 +340,7 @@ func _ManagementService_Login_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/Login", + FullMethod: ManagementService_Login_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).Login(ctx, req.(*EncryptedMessage)) @@ -344,21 +353,11 @@ func _ManagementService_Sync_Handler(srv interface{}, stream grpc.ServerStream) if err := stream.RecvMsg(m); err != nil { return err } - return srv.(ManagementServiceServer).Sync(m, &managementServiceSyncServer{stream}) + return srv.(ManagementServiceServer).Sync(m, &grpc.GenericServerStream[EncryptedMessage, EncryptedMessage]{ServerStream: stream}) } -type ManagementService_SyncServer interface { - Send(*EncryptedMessage) error - grpc.ServerStream -} - -type managementServiceSyncServer struct { - grpc.ServerStream -} - -func (x *managementServiceSyncServer) Send(m *EncryptedMessage) error { - return x.ServerStream.SendMsg(m) -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ManagementService_SyncServer = grpc.ServerStreamingServer[EncryptedMessage] func _ManagementService_GetServerKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Empty) @@ -370,7 +369,7 @@ func _ManagementService_GetServerKey_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/GetServerKey", + FullMethod: ManagementService_GetServerKey_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).GetServerKey(ctx, req.(*Empty)) @@ -388,7 +387,7 @@ func _ManagementService_IsHealthy_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/isHealthy", + FullMethod: ManagementService_IsHealthy_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).IsHealthy(ctx, req.(*Empty)) @@ -406,7 +405,7 @@ func _ManagementService_GetDeviceAuthorizationFlow_Handler(srv interface{}, ctx } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/GetDeviceAuthorizationFlow", + FullMethod: ManagementService_GetDeviceAuthorizationFlow_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).GetDeviceAuthorizationFlow(ctx, req.(*EncryptedMessage)) @@ -424,7 +423,7 @@ func _ManagementService_GetPKCEAuthorizationFlow_Handler(srv interface{}, ctx co } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/GetPKCEAuthorizationFlow", + FullMethod: ManagementService_GetPKCEAuthorizationFlow_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).GetPKCEAuthorizationFlow(ctx, req.(*EncryptedMessage)) @@ -442,7 +441,7 @@ func _ManagementService_SyncMeta_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/SyncMeta", + FullMethod: ManagementService_SyncMeta_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).SyncMeta(ctx, req.(*EncryptedMessage)) @@ -460,7 +459,7 @@ func _ManagementService_Logout_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/Logout", + FullMethod: ManagementService_Logout_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).Logout(ctx, req.(*EncryptedMessage)) @@ -469,30 +468,11 @@ func _ManagementService_Logout_Handler(srv interface{}, ctx context.Context, dec } func _ManagementService_Job_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(ManagementServiceServer).Job(&managementServiceJobServer{stream}) -} - -type ManagementService_JobServer interface { - Send(*EncryptedMessage) error - Recv() (*EncryptedMessage, error) - grpc.ServerStream -} - -type managementServiceJobServer struct { - grpc.ServerStream -} - -func (x *managementServiceJobServer) Send(m *EncryptedMessage) error { - return x.ServerStream.SendMsg(m) + return srv.(ManagementServiceServer).Job(&grpc.GenericServerStream[EncryptedMessage, EncryptedMessage]{ServerStream: stream}) } -func (x *managementServiceJobServer) Recv() (*EncryptedMessage, error) { - m := new(EncryptedMessage) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ManagementService_JobServer = grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage] func _ManagementService_CreateExpose_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(EncryptedMessage) @@ -504,7 +484,7 @@ func _ManagementService_CreateExpose_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/CreateExpose", + FullMethod: ManagementService_CreateExpose_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).CreateExpose(ctx, req.(*EncryptedMessage)) @@ -522,7 +502,7 @@ func _ManagementService_RenewExpose_Handler(srv interface{}, ctx context.Context } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/RenewExpose", + FullMethod: ManagementService_RenewExpose_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).RenewExpose(ctx, req.(*EncryptedMessage)) @@ -540,7 +520,7 @@ func _ManagementService_StopExpose_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/management.ManagementService/StopExpose", + FullMethod: ManagementService_StopExpose_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).StopExpose(ctx, req.(*EncryptedMessage)) From 62cf9e873b0f86b56d0b7dd166403f1dbf6e3c9f Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 16 May 2026 17:06:19 +0200 Subject: [PATCH 002/151] Track active VNC sessions in status and address CodeRabbit findings --- client/internal/engine_vnc.go | 11 +- client/internal/engine_vnc_console_linux.go | 3 +- client/proto/daemon.pb.go | 959 +++++++++++--------- client/proto/daemon.proto | 9 + client/server/server.go | 13 +- client/status/status.go | 66 +- client/status/status_test.go | 8 +- client/vnc/server/capture_darwin.go | 16 +- client/vnc/server/capture_windows.go | 7 +- client/vnc/server/input_windows.go | 9 +- client/vnc/server/rfb.go | 4 +- client/vnc/server/server.go | 74 +- 12 files changed, 708 insertions(+), 471 deletions(-) diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 6341a5cdd06..38de3feba22 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -24,6 +24,7 @@ const ( type vncServer interface { Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error Stop() error + ActiveSessions() []vncserver.ActiveSessionInfo } func (e *Engine) setupVNCPortRedirection() error { @@ -208,9 +209,13 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { }) } -// GetVNCServerStatus returns whether the VNC server is running. -func (e *Engine) GetVNCServerStatus() bool { - return e.vncSrv != nil +// GetVNCServerStatus returns whether the VNC server is running and the list +// of active VNC sessions. +func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) { + if e.vncSrv == nil { + return false, nil + } + return true, e.vncSrv.ActiveSessions() } func (e *Engine) stopVNCServer() error { diff --git a/client/internal/engine_vnc_console_linux.go b/client/internal/engine_vnc_console_linux.go index d2bdd24cef3..e04476e71f3 100644 --- a/client/internal/engine_vnc_console_linux.go +++ b/client/internal/engine_vnc_console_linux.go @@ -21,8 +21,7 @@ func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) } inj, err := vncserver.NewUInputInjector(w, h) if err != nil { - poller.Close() - return nil, nil, fmt.Errorf("uinput init: %w", err) + return poller, &vncserver.StubInputInjector{}, nil } return poller, inj, nil } diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 4ba4976753e..ad4353c9132 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -192,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Severity.Descriptor instead. func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52, 0} + return file_daemon_proto_rawDescGZIP(), []int{53, 0} } type SystemEvent_Category int32 @@ -247,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Category.Descriptor instead. func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52, 1} + return file_daemon_proto_rawDescGZIP(), []int{53, 1} } type EmptyRequest struct { @@ -2101,17 +2101,87 @@ func (x *SSHServerState) GetSessions() []*SSHSessionInfo { return nil } +// VNCSessionInfo contains information about an active VNC session +type VNCSessionInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + RemoteAddress string `protobuf:"bytes,1,opt,name=remoteAddress,proto3" json:"remoteAddress,omitempty"` + Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + JwtUsername string `protobuf:"bytes,4,opt,name=jwtUsername,proto3" json:"jwtUsername,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VNCSessionInfo) Reset() { + *x = VNCSessionInfo{} + mi := &file_daemon_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VNCSessionInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VNCSessionInfo) ProtoMessage() {} + +func (x *VNCSessionInfo) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VNCSessionInfo.ProtoReflect.Descriptor instead. +func (*VNCSessionInfo) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{21} +} + +func (x *VNCSessionInfo) GetRemoteAddress() string { + if x != nil { + return x.RemoteAddress + } + return "" +} + +func (x *VNCSessionInfo) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *VNCSessionInfo) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *VNCSessionInfo) GetJwtUsername() string { + if x != nil { + return x.JwtUsername + } + return "" +} + // VNCServerState contains the latest state of the VNC server type VNCServerState struct { state protoimpl.MessageState `protogen:"open.v1"` Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Sessions []*VNCSessionInfo `protobuf:"bytes,2,rep,name=sessions,proto3" json:"sessions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *VNCServerState) Reset() { *x = VNCServerState{} - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2123,7 +2193,7 @@ func (x *VNCServerState) String() string { func (*VNCServerState) ProtoMessage() {} func (x *VNCServerState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2136,7 +2206,7 @@ func (x *VNCServerState) ProtoReflect() protoreflect.Message { // Deprecated: Use VNCServerState.ProtoReflect.Descriptor instead. func (*VNCServerState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{21} + return file_daemon_proto_rawDescGZIP(), []int{22} } func (x *VNCServerState) GetEnabled() bool { @@ -2146,6 +2216,13 @@ func (x *VNCServerState) GetEnabled() bool { return false } +func (x *VNCServerState) GetSessions() []*VNCSessionInfo { + if x != nil { + return x.Sessions + } + return nil +} + // FullStatus contains the full state held by the Status instance type FullStatus struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2166,7 +2243,7 @@ type FullStatus struct { func (x *FullStatus) Reset() { *x = FullStatus{} - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2178,7 +2255,7 @@ func (x *FullStatus) String() string { func (*FullStatus) ProtoMessage() {} func (x *FullStatus) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2191,7 +2268,7 @@ func (x *FullStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use FullStatus.ProtoReflect.Descriptor instead. func (*FullStatus) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{22} + return file_daemon_proto_rawDescGZIP(), []int{23} } func (x *FullStatus) GetManagementState() *ManagementState { @@ -2280,7 +2357,7 @@ type ListNetworksRequest struct { func (x *ListNetworksRequest) Reset() { *x = ListNetworksRequest{} - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2292,7 +2369,7 @@ func (x *ListNetworksRequest) String() string { func (*ListNetworksRequest) ProtoMessage() {} func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2305,7 +2382,7 @@ func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksRequest.ProtoReflect.Descriptor instead. func (*ListNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{23} + return file_daemon_proto_rawDescGZIP(), []int{24} } type ListNetworksResponse struct { @@ -2317,7 +2394,7 @@ type ListNetworksResponse struct { func (x *ListNetworksResponse) Reset() { *x = ListNetworksResponse{} - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2329,7 +2406,7 @@ func (x *ListNetworksResponse) String() string { func (*ListNetworksResponse) ProtoMessage() {} func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2342,7 +2419,7 @@ func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksResponse.ProtoReflect.Descriptor instead. func (*ListNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{24} + return file_daemon_proto_rawDescGZIP(), []int{25} } func (x *ListNetworksResponse) GetRoutes() []*Network { @@ -2363,7 +2440,7 @@ type SelectNetworksRequest struct { func (x *SelectNetworksRequest) Reset() { *x = SelectNetworksRequest{} - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2375,7 +2452,7 @@ func (x *SelectNetworksRequest) String() string { func (*SelectNetworksRequest) ProtoMessage() {} func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2388,7 +2465,7 @@ func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksRequest.ProtoReflect.Descriptor instead. func (*SelectNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{25} + return file_daemon_proto_rawDescGZIP(), []int{26} } func (x *SelectNetworksRequest) GetNetworkIDs() []string { @@ -2420,7 +2497,7 @@ type SelectNetworksResponse struct { func (x *SelectNetworksResponse) Reset() { *x = SelectNetworksResponse{} - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2432,7 +2509,7 @@ func (x *SelectNetworksResponse) String() string { func (*SelectNetworksResponse) ProtoMessage() {} func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2445,7 +2522,7 @@ func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksResponse.ProtoReflect.Descriptor instead. func (*SelectNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{26} + return file_daemon_proto_rawDescGZIP(), []int{27} } type IPList struct { @@ -2457,7 +2534,7 @@ type IPList struct { func (x *IPList) Reset() { *x = IPList{} - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2469,7 +2546,7 @@ func (x *IPList) String() string { func (*IPList) ProtoMessage() {} func (x *IPList) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2482,7 +2559,7 @@ func (x *IPList) ProtoReflect() protoreflect.Message { // Deprecated: Use IPList.ProtoReflect.Descriptor instead. func (*IPList) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{27} + return file_daemon_proto_rawDescGZIP(), []int{28} } func (x *IPList) GetIps() []string { @@ -2505,7 +2582,7 @@ type Network struct { func (x *Network) Reset() { *x = Network{} - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2517,7 +2594,7 @@ func (x *Network) String() string { func (*Network) ProtoMessage() {} func (x *Network) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2530,7 +2607,7 @@ func (x *Network) ProtoReflect() protoreflect.Message { // Deprecated: Use Network.ProtoReflect.Descriptor instead. func (*Network) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{28} + return file_daemon_proto_rawDescGZIP(), []int{29} } func (x *Network) GetID() string { @@ -2582,7 +2659,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2594,7 +2671,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2607,7 +2684,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{29} + return file_daemon_proto_rawDescGZIP(), []int{30} } func (x *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -2664,7 +2741,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2676,7 +2753,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2689,7 +2766,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{30} + return file_daemon_proto_rawDescGZIP(), []int{31} } func (x *ForwardingRule) GetProtocol() string { @@ -2736,7 +2813,7 @@ type ForwardingRulesResponse struct { func (x *ForwardingRulesResponse) Reset() { *x = ForwardingRulesResponse{} - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2748,7 +2825,7 @@ func (x *ForwardingRulesResponse) String() string { func (*ForwardingRulesResponse) ProtoMessage() {} func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2761,7 +2838,7 @@ func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRulesResponse.ProtoReflect.Descriptor instead. func (*ForwardingRulesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{31} + return file_daemon_proto_rawDescGZIP(), []int{32} } func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { @@ -2784,7 +2861,7 @@ type DebugBundleRequest struct { func (x *DebugBundleRequest) Reset() { *x = DebugBundleRequest{} - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2796,7 +2873,7 @@ func (x *DebugBundleRequest) String() string { func (*DebugBundleRequest) ProtoMessage() {} func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2809,7 +2886,7 @@ func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleRequest.ProtoReflect.Descriptor instead. func (*DebugBundleRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{32} + return file_daemon_proto_rawDescGZIP(), []int{33} } func (x *DebugBundleRequest) GetAnonymize() bool { @@ -2851,7 +2928,7 @@ type DebugBundleResponse struct { func (x *DebugBundleResponse) Reset() { *x = DebugBundleResponse{} - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2863,7 +2940,7 @@ func (x *DebugBundleResponse) String() string { func (*DebugBundleResponse) ProtoMessage() {} func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2876,7 +2953,7 @@ func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleResponse.ProtoReflect.Descriptor instead. func (*DebugBundleResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{33} + return file_daemon_proto_rawDescGZIP(), []int{34} } func (x *DebugBundleResponse) GetPath() string { @@ -2908,7 +2985,7 @@ type GetLogLevelRequest struct { func (x *GetLogLevelRequest) Reset() { *x = GetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2920,7 +2997,7 @@ func (x *GetLogLevelRequest) String() string { func (*GetLogLevelRequest) ProtoMessage() {} func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2933,7 +3010,7 @@ func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelRequest.ProtoReflect.Descriptor instead. func (*GetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{34} + return file_daemon_proto_rawDescGZIP(), []int{35} } type GetLogLevelResponse struct { @@ -2945,7 +3022,7 @@ type GetLogLevelResponse struct { func (x *GetLogLevelResponse) Reset() { *x = GetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2957,7 +3034,7 @@ func (x *GetLogLevelResponse) String() string { func (*GetLogLevelResponse) ProtoMessage() {} func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2970,7 +3047,7 @@ func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelResponse.ProtoReflect.Descriptor instead. func (*GetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{35} + return file_daemon_proto_rawDescGZIP(), []int{36} } func (x *GetLogLevelResponse) GetLevel() LogLevel { @@ -2989,7 +3066,7 @@ type SetLogLevelRequest struct { func (x *SetLogLevelRequest) Reset() { *x = SetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3001,7 +3078,7 @@ func (x *SetLogLevelRequest) String() string { func (*SetLogLevelRequest) ProtoMessage() {} func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3014,7 +3091,7 @@ func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelRequest.ProtoReflect.Descriptor instead. func (*SetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{36} + return file_daemon_proto_rawDescGZIP(), []int{37} } func (x *SetLogLevelRequest) GetLevel() LogLevel { @@ -3032,7 +3109,7 @@ type SetLogLevelResponse struct { func (x *SetLogLevelResponse) Reset() { *x = SetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3044,7 +3121,7 @@ func (x *SetLogLevelResponse) String() string { func (*SetLogLevelResponse) ProtoMessage() {} func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3057,7 +3134,7 @@ func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelResponse.ProtoReflect.Descriptor instead. func (*SetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{37} + return file_daemon_proto_rawDescGZIP(), []int{38} } // State represents a daemon state entry @@ -3070,7 +3147,7 @@ type State struct { func (x *State) Reset() { *x = State{} - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3082,7 +3159,7 @@ func (x *State) String() string { func (*State) ProtoMessage() {} func (x *State) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3095,7 +3172,7 @@ func (x *State) ProtoReflect() protoreflect.Message { // Deprecated: Use State.ProtoReflect.Descriptor instead. func (*State) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{38} + return file_daemon_proto_rawDescGZIP(), []int{39} } func (x *State) GetName() string { @@ -3114,7 +3191,7 @@ type ListStatesRequest struct { func (x *ListStatesRequest) Reset() { *x = ListStatesRequest{} - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3126,7 +3203,7 @@ func (x *ListStatesRequest) String() string { func (*ListStatesRequest) ProtoMessage() {} func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3139,7 +3216,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead. func (*ListStatesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{39} + return file_daemon_proto_rawDescGZIP(), []int{40} } // ListStatesResponse contains a list of states @@ -3152,7 +3229,7 @@ type ListStatesResponse struct { func (x *ListStatesResponse) Reset() { *x = ListStatesResponse{} - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3164,7 +3241,7 @@ func (x *ListStatesResponse) String() string { func (*ListStatesResponse) ProtoMessage() {} func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3177,7 +3254,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead. func (*ListStatesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{40} + return file_daemon_proto_rawDescGZIP(), []int{41} } func (x *ListStatesResponse) GetStates() []*State { @@ -3198,7 +3275,7 @@ type CleanStateRequest struct { func (x *CleanStateRequest) Reset() { *x = CleanStateRequest{} - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3210,7 +3287,7 @@ func (x *CleanStateRequest) String() string { func (*CleanStateRequest) ProtoMessage() {} func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3223,7 +3300,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead. func (*CleanStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{41} + return file_daemon_proto_rawDescGZIP(), []int{42} } func (x *CleanStateRequest) GetStateName() string { @@ -3250,7 +3327,7 @@ type CleanStateResponse struct { func (x *CleanStateResponse) Reset() { *x = CleanStateResponse{} - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3262,7 +3339,7 @@ func (x *CleanStateResponse) String() string { func (*CleanStateResponse) ProtoMessage() {} func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3275,7 +3352,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead. func (*CleanStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{42} + return file_daemon_proto_rawDescGZIP(), []int{43} } func (x *CleanStateResponse) GetCleanedStates() int32 { @@ -3296,7 +3373,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3308,7 +3385,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3321,7 +3398,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{43} + return file_daemon_proto_rawDescGZIP(), []int{44} } func (x *DeleteStateRequest) GetStateName() string { @@ -3348,7 +3425,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3360,7 +3437,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3373,7 +3450,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{44} + return file_daemon_proto_rawDescGZIP(), []int{45} } func (x *DeleteStateResponse) GetDeletedStates() int32 { @@ -3392,7 +3469,7 @@ type SetSyncResponsePersistenceRequest struct { func (x *SetSyncResponsePersistenceRequest) Reset() { *x = SetSyncResponsePersistenceRequest{} - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3404,7 +3481,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string { func (*SetSyncResponsePersistenceRequest) ProtoMessage() {} func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3417,7 +3494,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{45} + return file_daemon_proto_rawDescGZIP(), []int{46} } func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool { @@ -3435,7 +3512,7 @@ type SetSyncResponsePersistenceResponse struct { func (x *SetSyncResponsePersistenceResponse) Reset() { *x = SetSyncResponsePersistenceResponse{} - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3447,7 +3524,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string { func (*SetSyncResponsePersistenceResponse) ProtoMessage() {} func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3460,7 +3537,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{46} + return file_daemon_proto_rawDescGZIP(), []int{47} } type TCPFlags struct { @@ -3477,7 +3554,7 @@ type TCPFlags struct { func (x *TCPFlags) Reset() { *x = TCPFlags{} - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3489,7 +3566,7 @@ func (x *TCPFlags) String() string { func (*TCPFlags) ProtoMessage() {} func (x *TCPFlags) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3502,7 +3579,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead. func (*TCPFlags) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{47} + return file_daemon_proto_rawDescGZIP(), []int{48} } func (x *TCPFlags) GetSyn() bool { @@ -3564,7 +3641,7 @@ type TracePacketRequest struct { func (x *TracePacketRequest) Reset() { *x = TracePacketRequest{} - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3576,7 +3653,7 @@ func (x *TracePacketRequest) String() string { func (*TracePacketRequest) ProtoMessage() {} func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3589,7 +3666,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead. func (*TracePacketRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{48} + return file_daemon_proto_rawDescGZIP(), []int{49} } func (x *TracePacketRequest) GetSourceIp() string { @@ -3667,7 +3744,7 @@ type TraceStage struct { func (x *TraceStage) Reset() { *x = TraceStage{} - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3679,7 +3756,7 @@ func (x *TraceStage) String() string { func (*TraceStage) ProtoMessage() {} func (x *TraceStage) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3692,7 +3769,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message { // Deprecated: Use TraceStage.ProtoReflect.Descriptor instead. func (*TraceStage) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49} + return file_daemon_proto_rawDescGZIP(), []int{50} } func (x *TraceStage) GetName() string { @@ -3733,7 +3810,7 @@ type TracePacketResponse struct { func (x *TracePacketResponse) Reset() { *x = TracePacketResponse{} - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3745,7 +3822,7 @@ func (x *TracePacketResponse) String() string { func (*TracePacketResponse) ProtoMessage() {} func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3758,7 +3835,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead. func (*TracePacketResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{50} + return file_daemon_proto_rawDescGZIP(), []int{51} } func (x *TracePacketResponse) GetStages() []*TraceStage { @@ -3783,7 +3860,7 @@ type SubscribeRequest struct { func (x *SubscribeRequest) Reset() { *x = SubscribeRequest{} - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3795,7 +3872,7 @@ func (x *SubscribeRequest) String() string { func (*SubscribeRequest) ProtoMessage() {} func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3808,7 +3885,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. func (*SubscribeRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51} + return file_daemon_proto_rawDescGZIP(), []int{52} } type SystemEvent struct { @@ -3826,7 +3903,7 @@ type SystemEvent struct { func (x *SystemEvent) Reset() { *x = SystemEvent{} - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3838,7 +3915,7 @@ func (x *SystemEvent) String() string { func (*SystemEvent) ProtoMessage() {} func (x *SystemEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3851,7 +3928,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead. func (*SystemEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52} + return file_daemon_proto_rawDescGZIP(), []int{53} } func (x *SystemEvent) GetId() string { @@ -3911,7 +3988,7 @@ type GetEventsRequest struct { func (x *GetEventsRequest) Reset() { *x = GetEventsRequest{} - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3923,7 +4000,7 @@ func (x *GetEventsRequest) String() string { func (*GetEventsRequest) ProtoMessage() {} func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3936,7 +4013,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead. func (*GetEventsRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53} + return file_daemon_proto_rawDescGZIP(), []int{54} } type GetEventsResponse struct { @@ -3948,7 +4025,7 @@ type GetEventsResponse struct { func (x *GetEventsResponse) Reset() { *x = GetEventsResponse{} - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3960,7 +4037,7 @@ func (x *GetEventsResponse) String() string { func (*GetEventsResponse) ProtoMessage() {} func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3973,7 +4050,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead. func (*GetEventsResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{54} + return file_daemon_proto_rawDescGZIP(), []int{55} } func (x *GetEventsResponse) GetEvents() []*SystemEvent { @@ -3993,7 +4070,7 @@ type SwitchProfileRequest struct { func (x *SwitchProfileRequest) Reset() { *x = SwitchProfileRequest{} - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4005,7 +4082,7 @@ func (x *SwitchProfileRequest) String() string { func (*SwitchProfileRequest) ProtoMessage() {} func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4018,7 +4095,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead. func (*SwitchProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{55} + return file_daemon_proto_rawDescGZIP(), []int{56} } func (x *SwitchProfileRequest) GetProfileName() string { @@ -4043,7 +4120,7 @@ type SwitchProfileResponse struct { func (x *SwitchProfileResponse) Reset() { *x = SwitchProfileResponse{} - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4055,7 +4132,7 @@ func (x *SwitchProfileResponse) String() string { func (*SwitchProfileResponse) ProtoMessage() {} func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4068,7 +4145,7 @@ func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileResponse.ProtoReflect.Descriptor instead. func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{56} + return file_daemon_proto_rawDescGZIP(), []int{57} } type SetConfigRequest struct { @@ -4118,7 +4195,7 @@ type SetConfigRequest struct { func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4130,7 +4207,7 @@ func (x *SetConfigRequest) String() string { func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4143,7 +4220,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{57} + return file_daemon_proto_rawDescGZIP(), []int{58} } func (x *SetConfigRequest) GetUsername() string { @@ -4406,7 +4483,7 @@ type SetConfigResponse struct { func (x *SetConfigResponse) Reset() { *x = SetConfigResponse{} - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4418,7 +4495,7 @@ func (x *SetConfigResponse) String() string { func (*SetConfigResponse) ProtoMessage() {} func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4431,7 +4508,7 @@ func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead. func (*SetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{58} + return file_daemon_proto_rawDescGZIP(), []int{59} } type AddProfileRequest struct { @@ -4444,7 +4521,7 @@ type AddProfileRequest struct { func (x *AddProfileRequest) Reset() { *x = AddProfileRequest{} - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4456,7 +4533,7 @@ func (x *AddProfileRequest) String() string { func (*AddProfileRequest) ProtoMessage() {} func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4469,7 +4546,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead. func (*AddProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{59} + return file_daemon_proto_rawDescGZIP(), []int{60} } func (x *AddProfileRequest) GetUsername() string { @@ -4494,7 +4571,7 @@ type AddProfileResponse struct { func (x *AddProfileResponse) Reset() { *x = AddProfileResponse{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4506,7 +4583,7 @@ func (x *AddProfileResponse) String() string { func (*AddProfileResponse) ProtoMessage() {} func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4519,7 +4596,7 @@ func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileResponse.ProtoReflect.Descriptor instead. func (*AddProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{61} } type RemoveProfileRequest struct { @@ -4532,7 +4609,7 @@ type RemoveProfileRequest struct { func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4544,7 +4621,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4557,7 +4634,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{62} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4582,7 +4659,7 @@ type RemoveProfileResponse struct { func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4594,7 +4671,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4607,7 +4684,7 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{63} } type ListProfilesRequest struct { @@ -4619,7 +4696,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4631,7 +4708,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4644,7 +4721,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *ListProfilesRequest) GetUsername() string { @@ -4663,7 +4740,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4675,7 +4752,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4688,7 +4765,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{65} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4708,7 +4785,7 @@ type Profile struct { func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4720,7 +4797,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4733,7 +4810,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *Profile) GetName() string { @@ -4758,7 +4835,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4770,7 +4847,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4783,7 +4860,7 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{67} } type GetActiveProfileResponse struct { @@ -4796,7 +4873,7 @@ type GetActiveProfileResponse struct { func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4808,7 +4885,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4821,7 +4898,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4848,7 +4925,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4860,7 +4937,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4873,7 +4950,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{69} } func (x *LogoutRequest) GetProfileName() string { @@ -4898,7 +4975,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4910,7 +4987,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4923,7 +5000,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{70} } type GetFeaturesRequest struct { @@ -4934,7 +5011,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4946,7 +5023,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4959,7 +5036,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{71} } type GetFeaturesResponse struct { @@ -4973,7 +5050,7 @@ type GetFeaturesResponse struct { func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4985,7 +5062,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4998,7 +5075,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{72} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -5030,7 +5107,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5042,7 +5119,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5055,7 +5132,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{73} } type TriggerUpdateResponse struct { @@ -5068,7 +5145,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5080,7 +5157,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5093,7 +5170,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{74} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5121,7 +5198,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5133,7 +5210,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5146,7 +5223,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5173,7 +5250,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5185,7 +5262,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5198,7 +5275,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5240,7 +5317,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5252,7 +5329,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5265,7 +5342,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5298,7 +5375,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5310,7 +5387,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5323,7 +5400,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5388,7 +5465,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5400,7 +5477,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5413,7 +5490,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5445,7 +5522,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5457,7 +5534,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5470,7 +5547,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5503,7 +5580,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5515,7 +5592,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5528,7 +5605,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{81} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5540,7 +5617,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5552,7 +5629,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5565,7 +5642,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{82} } // StopCPUProfileRequest for stopping CPU profiling @@ -5577,7 +5654,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5589,7 +5666,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5602,7 +5679,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{83} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5614,7 +5691,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5626,7 +5703,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5639,7 +5716,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{84} } type InstallerResultRequest struct { @@ -5650,7 +5727,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5662,7 +5739,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5675,7 +5752,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{85} } type InstallerResultResponse struct { @@ -5688,7 +5765,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5700,7 +5777,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5713,7 +5790,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{86} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5746,7 +5823,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5758,7 +5835,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5771,7 +5848,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5842,7 +5919,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5854,7 +5931,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5867,7 +5944,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{88} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5908,7 +5985,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5920,7 +5997,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5933,7 +6010,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{89} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5978,7 +6055,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5990,7 +6067,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6003,7 +6080,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{90} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -6057,7 +6134,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6069,7 +6146,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6082,7 +6159,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{91} } func (x *CapturePacket) GetData() []byte { @@ -6103,7 +6180,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6115,7 +6192,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6128,7 +6205,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{92} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6146,7 +6223,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6158,7 +6235,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6171,7 +6248,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{93} } type StopBundleCaptureRequest struct { @@ -6182,7 +6259,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6194,7 +6271,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6207,7 +6284,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{94} } type StopBundleCaptureResponse struct { @@ -6218,7 +6295,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6230,7 +6307,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6243,7 +6320,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{94} + return file_daemon_proto_rawDescGZIP(), []int{95} } type PortInfo_Range struct { @@ -6256,7 +6333,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6268,7 +6345,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6281,7 +6358,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead. func (*PortInfo_Range) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{29, 0} + return file_daemon_proto_rawDescGZIP(), []int{30, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -6505,9 +6582,15 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"*\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\x88\x01\n" + + "\x0eVNCSessionInfo\x12$\n" + + "\rremoteAddress\x18\x01 \x01(\tR\rremoteAddress\x12\x12\n" + + "\x04mode\x18\x02 \x01(\tR\x04mode\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12 \n" + + "\vjwtUsername\x18\x04 \x01(\tR\vjwtUsername\"^\n" + "\x0eVNCServerState\x12\x18\n" + - "\aenabled\x18\x01 \x01(\bR\aenabled\"\xef\x04\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.VNCSessionInfoR\bsessions\"\xef\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -6916,7 +6999,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 99) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -6943,208 +7026,210 @@ var file_daemon_proto_goTypes = []any{ (*NSGroupState)(nil), // 22: daemon.NSGroupState (*SSHSessionInfo)(nil), // 23: daemon.SSHSessionInfo (*SSHServerState)(nil), // 24: daemon.SSHServerState - (*VNCServerState)(nil), // 25: daemon.VNCServerState - (*FullStatus)(nil), // 26: daemon.FullStatus - (*ListNetworksRequest)(nil), // 27: daemon.ListNetworksRequest - (*ListNetworksResponse)(nil), // 28: daemon.ListNetworksResponse - (*SelectNetworksRequest)(nil), // 29: daemon.SelectNetworksRequest - (*SelectNetworksResponse)(nil), // 30: daemon.SelectNetworksResponse - (*IPList)(nil), // 31: daemon.IPList - (*Network)(nil), // 32: daemon.Network - (*PortInfo)(nil), // 33: daemon.PortInfo - (*ForwardingRule)(nil), // 34: daemon.ForwardingRule - (*ForwardingRulesResponse)(nil), // 35: daemon.ForwardingRulesResponse - (*DebugBundleRequest)(nil), // 36: daemon.DebugBundleRequest - (*DebugBundleResponse)(nil), // 37: daemon.DebugBundleResponse - (*GetLogLevelRequest)(nil), // 38: daemon.GetLogLevelRequest - (*GetLogLevelResponse)(nil), // 39: daemon.GetLogLevelResponse - (*SetLogLevelRequest)(nil), // 40: daemon.SetLogLevelRequest - (*SetLogLevelResponse)(nil), // 41: daemon.SetLogLevelResponse - (*State)(nil), // 42: daemon.State - (*ListStatesRequest)(nil), // 43: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 44: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 45: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 46: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 47: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 48: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 49: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 50: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 51: daemon.TCPFlags - (*TracePacketRequest)(nil), // 52: daemon.TracePacketRequest - (*TraceStage)(nil), // 53: daemon.TraceStage - (*TracePacketResponse)(nil), // 54: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 55: daemon.SubscribeRequest - (*SystemEvent)(nil), // 56: daemon.SystemEvent - (*GetEventsRequest)(nil), // 57: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 58: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 59: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 60: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 61: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 62: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 63: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 64: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 65: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 66: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 67: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 68: daemon.ListProfilesResponse - (*Profile)(nil), // 69: daemon.Profile - (*GetActiveProfileRequest)(nil), // 70: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 71: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 72: daemon.LogoutRequest - (*LogoutResponse)(nil), // 73: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 74: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 75: daemon.GetFeaturesResponse - (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 94: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse - nil, // 99: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range - nil, // 101: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 102: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp + (*VNCSessionInfo)(nil), // 25: daemon.VNCSessionInfo + (*VNCServerState)(nil), // 26: daemon.VNCServerState + (*FullStatus)(nil), // 27: daemon.FullStatus + (*ListNetworksRequest)(nil), // 28: daemon.ListNetworksRequest + (*ListNetworksResponse)(nil), // 29: daemon.ListNetworksResponse + (*SelectNetworksRequest)(nil), // 30: daemon.SelectNetworksRequest + (*SelectNetworksResponse)(nil), // 31: daemon.SelectNetworksResponse + (*IPList)(nil), // 32: daemon.IPList + (*Network)(nil), // 33: daemon.Network + (*PortInfo)(nil), // 34: daemon.PortInfo + (*ForwardingRule)(nil), // 35: daemon.ForwardingRule + (*ForwardingRulesResponse)(nil), // 36: daemon.ForwardingRulesResponse + (*DebugBundleRequest)(nil), // 37: daemon.DebugBundleRequest + (*DebugBundleResponse)(nil), // 38: daemon.DebugBundleResponse + (*GetLogLevelRequest)(nil), // 39: daemon.GetLogLevelRequest + (*GetLogLevelResponse)(nil), // 40: daemon.GetLogLevelResponse + (*SetLogLevelRequest)(nil), // 41: daemon.SetLogLevelRequest + (*SetLogLevelResponse)(nil), // 42: daemon.SetLogLevelResponse + (*State)(nil), // 43: daemon.State + (*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest + (*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse + (*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest + (*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse + (*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest + (*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse + (*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest + (*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse + (*TCPFlags)(nil), // 52: daemon.TCPFlags + (*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest + (*TraceStage)(nil), // 54: daemon.TraceStage + (*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse + (*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest + (*SystemEvent)(nil), // 57: daemon.SystemEvent + (*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest + (*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse + (*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest + (*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse + (*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest + (*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse + (*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest + (*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse + (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse + (*Profile)(nil), // 70: daemon.Profile + (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 73: daemon.LogoutRequest + (*LogoutResponse)(nil), // 74: daemon.LogoutResponse + (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse + (*TriggerUpdateRequest)(nil), // 77: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 78: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 79: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 80: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 81: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 82: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 83: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 84: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 85: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 86: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 87: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 88: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 89: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 90: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 91: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 92: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 93: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 94: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 95: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 96: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 97: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 98: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 99: daemon.StopBundleCaptureResponse + nil, // 100: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 101: daemon.PortInfo.Range + nil, // 102: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 103: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 104: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 26, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 103, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 27, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus + 104, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 104, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 103, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo - 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState - 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState - 18, // 8: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState - 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState - 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 56, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent - 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState - 25, // 14: daemon.FullStatus.vncServerState:type_name -> daemon.VNCServerState - 32, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 99, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 100, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 33, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 33, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 34, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule - 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel - 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 42, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State - 51, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 53, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 103, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 101, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 56, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 102, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 69, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 92, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 102, // 35: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 102, // 36: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration - 31, // 37: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 5, // 38: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 7, // 39: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 9, // 40: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 11, // 41: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 13, // 42: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 15, // 43: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 27, // 44: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 29, // 45: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 29, // 46: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 47: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 36, // 48: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 38, // 49: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 40, // 50: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 43, // 51: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 45, // 52: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 47, // 53: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 49, // 54: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 52, // 55: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 93, // 56: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 95, // 57: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 97, // 58: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest - 55, // 59: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 57, // 60: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 59, // 61: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 61, // 62: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 63, // 63: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 65, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 67, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 70, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 72, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 74, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 76, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 78, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 80, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 82, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 84, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 86, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 88, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 90, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 28, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 30, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 30, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 35, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 37, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 39, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 41, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 44, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 46, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 48, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 50, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 54, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 94, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 96, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 98, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 56, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 58, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 60, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 62, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 64, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 66, // 103: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 68, // 104: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 71, // 105: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 73, // 106: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 75, // 107: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 77, // 108: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 79, // 109: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 81, // 110: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 83, // 111: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 85, // 112: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 87, // 113: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 89, // 114: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 91, // 115: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 77, // [77:116] is the sub-list for method output_type - 38, // [38:77] is the sub-list for method input_type - 38, // [38:38] is the sub-list for extension type_name - 38, // [38:38] is the sub-list for extension extendee - 0, // [0:38] is the sub-list for field type_name + 25, // 6: daemon.VNCServerState.sessions:type_name -> daemon.VNCSessionInfo + 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 18, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 17, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState + 21, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState + 22, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 26, // 15: daemon.FullStatus.vncServerState:type_name -> daemon.VNCServerState + 33, // 16: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 100, // 17: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 101, // 18: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 34, // 19: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 34, // 20: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 35, // 21: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 0, // 22: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel + 0, // 23: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel + 43, // 24: daemon.ListStatesResponse.states:type_name -> daemon.State + 52, // 25: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 54, // 26: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 2, // 27: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 3, // 28: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 104, // 29: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 102, // 30: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 57, // 31: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 103, // 32: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 70, // 33: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 93, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 103, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 103, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 32, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 13, // 43: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 44: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 28, // 45: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 30, // 46: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 30, // 47: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 48: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 37, // 49: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 39, // 50: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 41, // 51: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 44, // 52: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 46, // 53: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 48, // 54: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 50, // 55: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 53, // 56: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 94, // 57: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 96, // 58: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 98, // 59: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 56, // 60: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 58, // 61: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 60, // 62: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 62, // 63: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 64, // 64: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 66, // 65: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 68, // 66: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 71, // 67: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 73, // 68: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 75, // 69: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 77, // 70: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 79, // 71: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 81, // 72: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 83, // 73: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 85, // 74: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 87, // 75: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 89, // 76: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 91, // 77: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 6, // 78: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 79: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 80: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 81: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 82: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 83: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 29, // 84: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 31, // 85: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 31, // 86: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 36, // 87: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 38, // 88: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 40, // 89: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 42, // 90: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 45, // 91: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 47, // 92: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 49, // 93: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 51, // 94: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 55, // 95: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 95, // 96: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 97, // 97: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 99, // 98: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 57, // 99: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 59, // 100: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 61, // 101: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 63, // 102: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 65, // 103: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 78, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 80, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 82, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 84, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 86, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 88, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 90, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 92, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 78, // [78:117] is the sub-list for method output_type + 39, // [39:78] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -7155,17 +7240,17 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[1].OneofWrappers = []any{} file_daemon_proto_msgTypes[5].OneofWrappers = []any{} file_daemon_proto_msgTypes[7].OneofWrappers = []any{} - file_daemon_proto_msgTypes[29].OneofWrappers = []any{ + file_daemon_proto_msgTypes[30].OneofWrappers = []any{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } - file_daemon_proto_msgTypes[48].OneofWrappers = []any{} file_daemon_proto_msgTypes[49].OneofWrappers = []any{} - file_daemon_proto_msgTypes[55].OneofWrappers = []any{} - file_daemon_proto_msgTypes[57].OneofWrappers = []any{} - file_daemon_proto_msgTypes[68].OneofWrappers = []any{} - file_daemon_proto_msgTypes[76].OneofWrappers = []any{} - file_daemon_proto_msgTypes[87].OneofWrappers = []any{ + file_daemon_proto_msgTypes[50].OneofWrappers = []any{} + file_daemon_proto_msgTypes[56].OneofWrappers = []any{} + file_daemon_proto_msgTypes[58].OneofWrappers = []any{} + file_daemon_proto_msgTypes[69].OneofWrappers = []any{} + file_daemon_proto_msgTypes[77].OneofWrappers = []any{} + file_daemon_proto_msgTypes[88].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7174,7 +7259,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 98, + NumMessages: 99, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 2bc37b684bc..6c002b35363 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -398,9 +398,18 @@ message SSHServerState { repeated SSHSessionInfo sessions = 2; } +// VNCSessionInfo contains information about an active VNC session +message VNCSessionInfo { + string remoteAddress = 1; + string mode = 2; + string username = 3; + string jwtUsername = 4; +} + // VNCServerState contains the latest state of the VNC server message VNCServerState { bool enabled = 1; + repeated VNCSessionInfo sessions = 2; } // FullStatus contains the full state held by the Status instance diff --git a/client/server/server.go b/client/server/server.go index 5465abfc23c..d53a0471baa 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1192,8 +1192,19 @@ func (s *Server) getVNCServerState() *proto.VNCServerState { return nil } + enabled, sessions := engine.GetVNCServerStatus() + pbSessions := make([]*proto.VNCSessionInfo, 0, len(sessions)) + for _, sess := range sessions { + pbSessions = append(pbSessions, &proto.VNCSessionInfo{ + RemoteAddress: sess.RemoteAddress, + Mode: sess.Mode, + Username: sess.Username, + JwtUsername: sess.JWTUsername, + }) + } return &proto.VNCServerState{ - Enabled: engine.GetVNCServerStatus(), + Enabled: enabled, + Sessions: pbSessions, } } diff --git a/client/status/status.go b/client/status/status.go index 0ea89927f80..3cd4fe545dc 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -131,8 +131,16 @@ type SSHServerStateOutput struct { Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"` } +type VNCSessionOutput struct { + RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"` + Mode string `json:"mode" yaml:"mode"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + JWTUsername string `json:"jwtUsername,omitempty" yaml:"jwtUsername,omitempty"` +} + type VNCServerStateOutput struct { - Enabled bool `json:"enabled" yaml:"enabled"` + Enabled bool `json:"enabled" yaml:"enabled"` + Sessions []VNCSessionOutput `json:"sessions" yaml:"sessions"` } type OutputOverview struct { @@ -178,9 +186,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO relayOverview := mapRelays(pbFullStatus.GetRelays()) sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState()) - vncServerOverview := VNCServerStateOutput{ - Enabled: pbFullStatus.GetVncServerState().GetEnabled(), - } + vncServerOverview := mapVNCServer(pbFullStatus.GetVncServerState()) peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter) overview := OutputOverview{ @@ -280,6 +286,25 @@ func mapSSHServer(sshServerState *proto.SSHServerState) SSHServerStateOutput { } } +func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput { + if state == nil { + return VNCServerStateOutput{Sessions: []VNCSessionOutput{}} + } + sessions := make([]VNCSessionOutput, 0, len(state.GetSessions())) + for _, sess := range state.GetSessions() { + sessions = append(sessions, VNCSessionOutput{ + RemoteAddress: sess.GetRemoteAddress(), + Mode: sess.GetMode(), + Username: sess.GetUsername(), + JWTUsername: sess.GetJwtUsername(), + }) + } + return VNCServerStateOutput{ + Enabled: state.GetEnabled(), + Sessions: sessions, + } +} + func mapPeers( peers []*proto.PeerState, statusFilter string, @@ -544,7 +569,30 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS vncServerStatus := "Disabled" if o.VNCServerState.Enabled { - vncServerStatus = "Enabled" + vncSessionCount := len(o.VNCServerState.Sessions) + if vncSessionCount > 0 { + sessionWord := "session" + if vncSessionCount > 1 { + sessionWord = "sessions" + } + vncServerStatus = fmt.Sprintf("Enabled (%d active %s)", vncSessionCount, sessionWord) + } else { + vncServerStatus = "Enabled" + } + + if showSSHSessions && vncSessionCount > 0 { + for _, sess := range o.VNCServerState.Sessions { + var line string + if sess.JWTUsername != "" { + line = fmt.Sprintf("[%s@%s -> %s] mode=%s", + sess.JWTUsername, sess.RemoteAddress, sess.Username, sess.Mode) + } else { + line = fmt.Sprintf("[%s] mode=%s user=%s", + sess.RemoteAddress, sess.Mode, sess.Username) + } + vncServerStatus += "\n " + line + } + } } peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total) @@ -1011,4 +1059,12 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { } overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command) } + + for i, sess := range overview.VNCServerState.Sessions { + if host, port, err := net.SplitHostPort(sess.RemoteAddress); err == nil { + overview.VNCServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port) + } else { + overview.VNCServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(sess.RemoteAddress) + } + } } diff --git a/client/status/status_test.go b/client/status/status_test.go index ee0fd27182b..52118d01461 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -240,6 +240,10 @@ var overview = OutputOverview{ Enabled: false, Sessions: []SSHSessionOutput{}, }, + VNCServerState: VNCServerStateOutput{ + Enabled: false, + Sessions: []VNCSessionOutput{}, + }, } func TestConversionFromFullStatusToOutputOverview(t *testing.T) { @@ -406,7 +410,8 @@ func TestParsingToJSON(t *testing.T) { "sessions":[] }, "vncServer":{ - "enabled":false + "enabled":false, + "sessions":[] } }` // @formatter:on @@ -518,6 +523,7 @@ sshServer: sessions: [] vncServer: enabled: false + sessions: [] ` assert.Equal(t, expectedYAML, yaml) diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index 04db7f081d0..ac32449115a 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -285,21 +285,25 @@ func (c *CGCapturer) Capture() (*image.RGBA, error) { case bytesPerPixel == 4 && ds == 2: convertBGRAToRGBADownscale2(img.Pix, img.Stride, src, bytesPerRow, outW, outH) default: - convertBGRAToRGBAGeneric(img.Pix, img.Stride, src, bytesPerRow, outW, outH, bytesPerPixel, ds) + convertBGRAToRGBAGeneric(img.Pix, img.Stride, src, bytesPerRow, bgraDownscaleParams{outW: outW, outH: outH, bytesPerPixel: bytesPerPixel, ds: ds}) } return img, nil } +type bgraDownscaleParams struct { + outW, outH, bytesPerPixel, ds int +} + // convertBGRAToRGBAGeneric is the slow per-pixel fallback for non-4-bytes // or non-1/2 downscale formats. Always available regardless of the source // format quirks the fast paths optimize for. -func convertBGRAToRGBAGeneric(dst []byte, dstStride int, src []byte, srcStride, outW, outH, bytesPerPixel, ds int) { - for row := 0; row < outH; row++ { - srcOff := row * ds * srcStride +func convertBGRAToRGBAGeneric(dst []byte, dstStride int, src []byte, srcStride int, p bgraDownscaleParams) { + for row := 0; row < p.outH; row++ { + srcOff := row * p.ds * srcStride dstOff := row * dstStride - for col := 0; col < outW; col++ { - si := srcOff + col*ds*bytesPerPixel + for col := 0; col < p.outW; col++ { + si := srcOff + col*p.ds*p.bytesPerPixel di := dstOff + col*4 dst[di+0] = src[si+2] dst[di+1] = src[si+1] diff --git a/client/vnc/server/capture_windows.go b/client/vnc/server/capture_windows.go index 28d0f2911e8..1376ca8227d 100644 --- a/client/vnc/server/capture_windows.go +++ b/client/vnc/server/capture_windows.go @@ -321,7 +321,7 @@ func (c *DesktopCapturer) Width() int { c.mu.Lock() w := c.w c.mu.Unlock() - if w == 0 { + if w == 0 && c.clients.Load() > 0 { _, _ = c.Capture() c.mu.Lock() w = c.w @@ -331,12 +331,13 @@ func (c *DesktopCapturer) Width() int { } // Height returns the current screen height, triggering a capture if the -// worker hasn't initialised yet (see Width). +// worker hasn't initialised yet (see Width). Returns 0 while no client is +// connected so callers don't deadlock against a parked worker. func (c *DesktopCapturer) Height() int { c.mu.Lock() h := c.h c.mu.Unlock() - if h == 0 { + if h == 0 && c.clients.Load() > 0 { _, _ = c.Capture() c.mu.Lock() h = c.h diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index 85e7a00bb93..1a0ea22d22f 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -35,9 +35,10 @@ const ( wheelDelta = 120 - keyeventfKeyUp = 0x0002 - keyeventfUnicode = 0x0004 - keyeventfScanCode = 0x0008 + keyeventfExtendedKey = 0x0001 + keyeventfKeyUp = 0x0002 + keyeventfUnicode = 0x0004 + keyeventfScanCode = 0x0008 ) // winlogonDesktopName is the name of the Windows secure desktop that hosts the @@ -234,7 +235,7 @@ func (w *WindowsInputInjector) doInjectKey(keysym uint32, down bool) { flags |= keyeventfKeyUp } if extended { - flags |= keyeventfScanCode + flags |= keyeventfExtendedKey } sendKeyInput(vk, 0, flags) } diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index bd7684eb997..4822c0abf37 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -364,8 +364,8 @@ type rectCoalescer struct { curY int } -func newRectCoalescer(cap int) *rectCoalescer { - return &rectCoalescer{out: make([][4]int, 0, cap)} +func newRectCoalescer(capacity int) *rectCoalescer { + return &rectCoalescer{out: make([][4]int, 0, capacity)} } // consume processes one rect from the (row-ordered) input. diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index a7451eb87e5..27149a2252d 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -147,6 +147,18 @@ type Server struct { authorizer *sshauth.Authorizer netstackNet *netstack.Net agentToken []byte // raw token bytes for agent-mode auth + + sessionsMu sync.Mutex + sessionSeq uint64 + sessions map[uint64]ActiveSessionInfo +} + +// ActiveSessionInfo describes a currently connected VNC client. +type ActiveSessionInfo struct { + RemoteAddress string + Mode string + Username string + JWTUsername string } // vncSession provides capturer and injector for a virtual display session. @@ -174,7 +186,34 @@ func New(capturer ScreenCapturer, injector InputInjector, password string) *Serv password: password, authorizer: sshauth.NewAuthorizer(), log: log.WithField("component", "vnc-server"), + sessions: make(map[uint64]ActiveSessionInfo), + } +} + +// ActiveSessions returns a snapshot of currently connected VNC clients. +func (s *Server) ActiveSessions() []ActiveSessionInfo { + s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + out := make([]ActiveSessionInfo, 0, len(s.sessions)) + for _, info := range s.sessions { + out = append(out, info) } + return out +} + +func (s *Server) addSession(info ActiveSessionInfo) uint64 { + s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + s.sessionSeq++ + id := s.sessionSeq + s.sessions[id] = info + return id +} + +func (s *Server) removeSession(id uint64) { + s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + delete(s.sessions, id) } // SetServiceMode enables proxy-to-agent mode for Windows service operation. @@ -408,7 +447,7 @@ func (s *Server) handleConnection(conn net.Conn) { conn.Close() return } - connLog, ok := s.authorizeJWT(conn, header, connLog) + connLog, jwtUserID, ok := s.authorizeJWT(conn, header, connLog) if !ok { return } @@ -419,6 +458,14 @@ func (s *Server) handleConnection(conn net.Conn) { } defer sessionCleanup() + sessionID := s.addSession(ActiveSessionInfo{ + RemoteAddress: conn.RemoteAddr().String(), + Mode: modeString(header.mode), + Username: header.username, + JWTUsername: jwtUserID, + }) + defer s.removeSession(sessionID) + if err := s.validateCapturer(capturer); err != nil { rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("screen capturer: %v", err))) connLog.Warnf("capturer not ready: %v", err) @@ -686,23 +733,24 @@ func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool { } // authorizeJWT performs JWT validation when auth is enabled. Returns the -// enriched log entry and ok=false if the connection was rejected. -func (s *Server) authorizeJWT(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, bool) { +// enriched log entry, jwt user ID (empty when auth disabled), and ok=false +// if the connection was rejected. +func (s *Server) authorizeJWT(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) { if s.disableAuth { - return connLog, true + return connLog, "", true } if s.jwtConfig == nil { rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) connLog.Warn("auth rejected: no identity provider configured") - return connLog, false + return connLog, "", false } jwtUserID, err := s.authenticateJWT(header) if err != nil { rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) connLog.Warnf("auth rejected: %v", err) - return connLog, false + return connLog, "", false } - return connLog.WithField("jwt_user", jwtUserID), true + return connLog.WithField("jwt_user", jwtUserID), jwtUserID, true } // acquireSessionResources returns the capturer/injector to use for this @@ -752,3 +800,15 @@ func (s *Server) acquireAttachSession() ScreenCapturer { func attachSessionCleanup() { // Attach mode keeps the shared capturer; nothing to release per session. } + +// modeString returns a human-readable session mode name. +func modeString(m byte) string { + switch m { + case ModeAttach: + return "attach" + case ModeSession: + return "session" + default: + return "unknown" + } +} From 7123e6d1f4e3b505c59e626eeaed1212413e8bea Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 16 May 2026 17:23:36 +0200 Subject: [PATCH 003/151] Fix Windows lint errcheck/unused and Linux nilerr in console VNC fallback --- client/internal/engine_vnc_console_linux.go | 3 +++ client/vnc/server/agent_windows.go | 12 +++--------- client/vnc/server/input_windows.go | 8 +------- client/vnc/server/keysym_typetext.go | 2 ++ client/vnc/server/server_windows.go | 2 +- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/client/internal/engine_vnc_console_linux.go b/client/internal/engine_vnc_console_linux.go index e04476e71f3..68ea3616c06 100644 --- a/client/internal/engine_vnc_console_linux.go +++ b/client/internal/engine_vnc_console_linux.go @@ -5,6 +5,8 @@ package internal import ( "fmt" + log "github.com/sirupsen/logrus" + vncserver "github.com/netbirdio/netbird/client/vnc/server" ) @@ -21,6 +23,7 @@ func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) } inj, err := vncserver.NewUInputInjector(w, h) if err != nil { + log.Debugf("uinput unavailable, falling back to view-only VNC: %v", err) return poller, &vncserver.StubInputInjector{}, nil } return poller, inj, nil diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index a0d2790802b..6a777b442ec 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -91,12 +91,6 @@ const ( wtsDisconnected = 4 ) -type wtsSessionInfo struct { - SessionID uint32 - WinStationName [66]byte // actually *uint16, but we just need the struct size - State uint32 -} - // getActiveSessionID returns the session ID of the best session to attach to. // On a Windows Server with no console display attached, session 1 still // reports WTSActive (login screen "owns" the console), so a naive @@ -176,7 +170,7 @@ func reapOrphanOnPort(portStr string) { log.Warnf("reap on port %d: open PID=%d: %v", port, pid, err) return } - defer windows.CloseHandle(h) + defer func() { _ = windows.CloseHandle(h) }() if !isOurAgentProcess(h) { log.Warnf("reap on port %d: PID=%d is not a netbird vnc-agent, leaving it alone", port, pid) return @@ -307,7 +301,7 @@ func getSystemTokenForSession(sessionID uint32) (windows.Token, error) { return dup, nil } -const agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" +const agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" // #nosec G101 -- env var name, not a credential // injectEnvVar appends a KEY=VALUE entry to a Unicode environment block. // The block is a sequence of null-terminated UTF-16 strings, terminated by @@ -698,7 +692,7 @@ func (m *sessionManager) killAgent() { // error output, panic stack traces) are forwarded verbatim so failures // during early agent startup remain visible. func relogAgentOutput(pipe windows.Handle) { - defer windows.CloseHandle(pipe) + defer func() { _ = windows.CloseHandle(pipe) }() f := os.NewFile(uintptr(pipe), "vnc-agent-stderr") defer f.Close() diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index 1a0ea22d22f..0dea1f343bc 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -41,12 +41,6 @@ const ( keyeventfScanCode = 0x0008 ) -// winlogonDesktopName is the name of the Windows secure desktop that hosts the -// logon UI, Ctrl+Alt+Del screen, UAC prompts, and credential dialogs. Its -// clipboard is isolated from the interactive Default desktop, so pasting via -// the clipboard API does not work there. We fall back to synthesizing the -// text as Unicode keystrokes. -const winlogonDesktopName = "Winlogon" // maxTypedClipboardChars caps the number of characters we will synthesize as // keystrokes when falling back on the Winlogon desktop. Passwords are short; @@ -258,7 +252,7 @@ func signalSAS() { return } ev := windows.Handle(h) - defer windows.CloseHandle(ev) + defer func() { _ = windows.CloseHandle(ev) }() if err := windows.SetEvent(ev); err != nil { log.Warnf("SetEvent SAS: %v", err) } else { diff --git a/client/vnc/server/keysym_typetext.go b/client/vnc/server/keysym_typetext.go index e74c23967fd..788d65eb4c3 100644 --- a/client/vnc/server/keysym_typetext.go +++ b/client/vnc/server/keysym_typetext.go @@ -1,3 +1,5 @@ +//go:build !windows + package server // keysymForASCIIRune maps an ASCII rune to (X11 keysym for the unshifted diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 97d6b539345..6caec5cdd96 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -166,7 +166,7 @@ func createSASEvent() (windows.Handle, bool) { // signalled, until ctx is cancelled. Recovers from panics inside SendSAS so // a future ABI surprise doesn't tear down the service. func runSASListenerLoop(ctx context.Context, ev windows.Handle) { - defer windows.CloseHandle(ev) + defer func() { _ = windows.CloseHandle(ev) }() defer func() { if r := recover(); r != nil { log.Warnf("SAS listener recovered from panic: %v", r) From 9b5541d17d8e712fc36243fdcd58fcf2290a9e53 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 16 May 2026 22:11:28 +0200 Subject: [PATCH 004/151] Extract session-address anonymization helper to lower status complexity --- client/status/status.go | 43 +++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/client/status/status.go b/client/status/status.go index 3cd4fe545dc..e599727f833 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -1024,6 +1024,19 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.Relays.Details[i] = detail } + anonymizeNSServerGroups(a, overview) + + for i, route := range overview.Networks { + overview.Networks[i] = a.AnonymizeRoute(route) + } + + overview.FQDN = a.AnonymizeDomain(overview.FQDN) + + anonymizeEvents(a, overview) + anonymizeServerSessions(a, overview) +} + +func anonymizeNSServerGroups(a *anonymize.Anonymizer, overview *OutputOverview) { for i, nsGroup := range overview.NSServerGroups { for j, domain := range nsGroup.Domains { overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain) @@ -1035,13 +1048,9 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { } } } +} - for i, route := range overview.Networks { - overview.Networks[i] = a.AnonymizeRoute(route) - } - - overview.FQDN = a.AnonymizeDomain(overview.FQDN) - +func anonymizeEvents(a *anonymize.Anonymizer, overview *OutputOverview) { for i, event := range overview.Events { overview.Events[i].Message = a.AnonymizeString(event.Message) overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage) @@ -1050,21 +1059,21 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { event.Metadata[k] = a.AnonymizeString(v) } } +} + +func anonymizeRemoteAddress(a *anonymize.Anonymizer, addr string) string { + if host, port, err := net.SplitHostPort(addr); err == nil { + return fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port) + } + return a.AnonymizeIPString(addr) +} +func anonymizeServerSessions(a *anonymize.Anonymizer, overview *OutputOverview) { for i, session := range overview.SSHServerState.Sessions { - if host, port, err := net.SplitHostPort(session.RemoteAddress); err == nil { - overview.SSHServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port) - } else { - overview.SSHServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(session.RemoteAddress) - } + overview.SSHServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, session.RemoteAddress) overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command) } - for i, sess := range overview.VNCServerState.Sessions { - if host, port, err := net.SplitHostPort(sess.RemoteAddress); err == nil { - overview.VNCServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port) - } else { - overview.VNCServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(sess.RemoteAddress) - } + overview.VNCServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, sess.RemoteAddress) } } From 738c585ee74eda93f5280d731eb00485ae3d47a7 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 06:32:31 +0200 Subject: [PATCH 005/151] Guard VNC session negotiated encoding state with RWMutex --- client/vnc/server/session.go | 53 +++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 08d37ee135a..3b09bda0187 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -43,10 +43,11 @@ type session struct { log *log.Entry writeMu sync.Mutex - // pf and useZlib/zlib are written by messageLoop before the first FB - // update request arrives (SetPixelFormat/SetEncodings happen during the - // client handshake), and only read from the encoder goroutine. Fine - // without locks because of that ordering invariant. + // encMu guards the negotiated pixel format and encoding state below. + // messageLoop writes these on SetPixelFormat/SetEncodings, which RFB + // clients may send at any time after the handshake, while encoderLoop + // reads them on every frame. + encMu sync.RWMutex pf clientPixelFormat useZlib bool useHextile bool @@ -288,7 +289,10 @@ func (s *session) handleSetPixelFormat() error { if _, err := io.ReadFull(s.conn, buf[:]); err != nil { return fmt.Errorf("read SetPixelFormat: %w", err) } - s.pf = parsePixelFormat(buf[3:19]) + pf := parsePixelFormat(buf[3:19]) + s.encMu.Lock() + s.pf = pf + s.encMu.Unlock() return nil } @@ -311,6 +315,7 @@ func (s *session) handleSetEncodings() error { } var encs []string + s.encMu.Lock() for i := range int(numEnc) { enc := int32(binary.BigEndian.Uint32(buf[i*4 : i*4+4])) switch enc { @@ -331,6 +336,7 @@ func (s *session) handleSetEncodings() error { encs = append(encs, "tight") } } + s.encMu.Unlock() if len(encs) > 0 { s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) } @@ -504,11 +510,17 @@ func (s *session) sendEmptyUpdate() error { func (s *session) sendFullUpdate(img *image.RGBA) error { w, h := s.serverW, s.serverH + s.encMu.RLock() + pf := s.pf + useZlib := s.useZlib + zlib := s.zlib + s.encMu.RUnlock() + var buf []byte - if s.useZlib && s.zlib != nil { - buf = encodeZlibRect(img, s.pf, 0, 0, w, h, s.zlib) + if useZlib && zlib != nil { + buf = encodeZlibRect(img, pf, 0, 0, w, h, zlib) } else { - buf = encodeRawRect(img, s.pf, 0, 0, w, h) + buf = encodeRawRect(img, pf, 0, 0, w, h) } s.writeMu.Lock() @@ -551,14 +563,23 @@ func (s *session) sendDirtyRects(img *image.RGBA, rects [][4]int) error { // Output omits the 4-byte FramebufferUpdate header; callers combine multiple // tiles into one message. func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { - if s.useHextile { + s.encMu.RLock() + pf := s.pf + useHextile := s.useHextile + useTight := s.useTight + tight := s.tight + useZlib := s.useZlib + zlib := s.zlib + s.encMu.RUnlock() + + if useHextile { if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform { r := byte(pixel) g := byte(pixel >> 8) b := byte(pixel >> 16) - return encodeHextileSolidRect(r, g, b, s.pf, rect{x, y, w, h}) + return encodeHextileSolidRect(r, g, b, pf, rect{x, y, w, h}) } - // Full Hextile encoder disabled pending investigation of 16×16 + // Full Hextile encoder disabled pending investigation of 16x16 // red-tile artifacts on Windows. Solid-fill fast path is safe. } // Larger merged rects: prefer Tight (JPEG for photo-like, Basic+zlib @@ -566,13 +587,13 @@ func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { // compatible with Tight's mandatory 24-bit RGB TPIXEL encoding. Tight is // dramatically better than RFB Zlib on photographic content and // competitive on UI. - if s.useTight && s.tight != nil && pfIsTightCompatible(s.pf) { - return encodeTightRect(img, s.pf, x, y, w, h, s.tight) + if useTight && tight != nil && pfIsTightCompatible(pf) { + return encodeTightRect(img, pf, x, y, w, h, tight) } - if s.useZlib && s.zlib != nil { - return encodeZlibRect(img, s.pf, x, y, w, h, s.zlib)[4:] + if useZlib && zlib != nil { + return encodeZlibRect(img, pf, x, y, w, h, zlib)[4:] } - return encodeRawRect(img, s.pf, x, y, w, h)[4:] + return encodeRawRect(img, pf, x, y, w, h)[4:] } func (s *session) handleKeyEvent() error { From 94068d3ebc486ad47cd6c04e57a51279e7c5f6ab Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 06:32:50 +0200 Subject: [PATCH 006/151] Drop -ac from Xvfb/Xorg invocations to keep xhost localuser grant authoritative --- client/vnc/server/virtual_x11.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go index cf12f1654f8..4575a0aabe3 100644 --- a/client/vnc/server/virtual_x11.go +++ b/client/vnc/server/virtual_x11.go @@ -249,7 +249,6 @@ func (vs *VirtualSession) startXvfbDirect() error { geom := fmt.Sprintf("%dx%dx24", vs.width, vs.height) vs.xvfb = exec.Command("Xvfb", vs.display, "-screen", "0", geom, - "-ac", "-nolisten", "tcp", ) vs.xvfb.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGTERM} @@ -306,7 +305,6 @@ EndSection "-config", confPath, "-noreset", "-nolisten", "tcp", - "-ac", ) vs.xvfb.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGTERM} From a8541a152993afc516f22aa4d7ee635220553ce4 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 06:33:23 +0200 Subject: [PATCH 007/151] Apply posture and validated-peers filtering on ResourceTypePeer policy resolution --- management/server/types/account.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/management/server/types/account.go b/management/server/types/account.go index 59c64d1ff17..a5cc4d010a7 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -906,7 +906,20 @@ func (a *Account) resolveRuleEndpoint( validatedPeersMap map[string]struct{}, ) ([]*nbpeer.Peer, bool) { if resource.Type == ResourceTypePeer && resource.ID != "" { - return a.getPeerFromResource(resource, peerID) + resolvedPeer := a.GetPeer(resource.ID) + if resolvedPeer == nil { + return []*nbpeer.Peer{}, false + } + if len(postureChecks) > 0 && !a.validatePostureChecksOnPeer(ctx, postureChecks, resolvedPeer.ID) { + return []*nbpeer.Peer{}, false + } + if _, ok := validatedPeersMap[resolvedPeer.ID]; !ok { + return []*nbpeer.Peer{}, false + } + if resolvedPeer.ID == peerID { + return []*nbpeer.Peer{}, true + } + return []*nbpeer.Peer{resolvedPeer}, false } return a.getAllPeersFromGroups(ctx, groups, peerID, postureChecks, validatedPeersMap) } From 8bf13b0d0ced050a059a4c9b43f795a492230df0 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 06:33:27 +0200 Subject: [PATCH 008/151] Merge SSH wildcard authorized users across matching rules --- management/server/types/policy_authorized_users.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index 6452a5593c8..c56a689caa0 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -62,7 +62,12 @@ func applyResolvedRuleToState( return } state.sshEnabled = true - state.authorizedUsers[auth.Wildcard] = cb.getAllowedUserIDs() + if state.authorizedUsers[auth.Wildcard] == nil { + state.authorizedUsers[auth.Wildcard] = make(map[string]struct{}) + } + for userID := range cb.getAllowedUserIDs() { + state.authorizedUsers[auth.Wildcard][userID] = struct{}{} + } } } From fa90283781bc2f53b8f01592d65f1c7221632998 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 06:37:42 +0200 Subject: [PATCH 009/151] Extract wildcard user merge helper to satisfy case-clause length --- .../server/types/policy_authorized_users.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index c56a689caa0..c2363fffcfc 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -62,12 +62,16 @@ func applyResolvedRuleToState( return } state.sshEnabled = true - if state.authorizedUsers[auth.Wildcard] == nil { - state.authorizedUsers[auth.Wildcard] = make(map[string]struct{}) - } - for userID := range cb.getAllowedUserIDs() { - state.authorizedUsers[auth.Wildcard][userID] = struct{}{} - } + mergeWildcardUsers(state.authorizedUsers, cb.getAllowedUserIDs()) + } +} + +func mergeWildcardUsers(dst map[string]map[string]struct{}, users map[string]struct{}) { + if dst[auth.Wildcard] == nil { + dst[auth.Wildcard] = make(map[string]struct{}) + } + for userID := range users { + dst[auth.Wildcard][userID] = struct{}{} } } From d6d3fa95c7fbef0b3114c36d11da27fcb7ae9d54 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 06:48:46 +0200 Subject: [PATCH 010/151] Drop unused getPeerFromResource helper --- management/server/types/account.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/management/server/types/account.go b/management/server/types/account.go index a5cc4d010a7..b39d414fdbe 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -1064,19 +1064,6 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe return filteredPeers, peerInGroups } -func (a *Account) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { - peer := a.GetPeer(resource.ID) - if peer == nil { - return []*nbpeer.Peer{}, false - } - - if peer.ID == peerID { - return []*nbpeer.Peer{}, true - } - - return []*nbpeer.Peer{peer}, false -} - // validatePostureChecksOnPeer validates the posture checks on a peer func (a *Account) validatePostureChecksOnPeer(ctx context.Context, sourcePostureChecksID []string, peerID string) bool { peer, ok := a.Peers[peerID] From 44ed0c19924e96bc0c12dad53590651d71622769 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:13:46 +0200 Subject: [PATCH 011/151] Drop xclip-no-selection trace log that fires every 2s on Xvfb --- client/vnc/server/input_x11.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index ca1ca30a1c3..6696552ee01 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -260,7 +260,9 @@ func (x *X11InputInjector) GetClipboard() string { cmd.Env = x.clipboardEnv() out, err := cmd.Output() if err != nil { - log.Tracef("get clipboard via %s: %v", x.clipboardToolName, err) + // Exit status 1 just means there is no STRING selection set yet, + // which is the steady state on a fresh Xvfb session — logging it + // every clipboard poll (2s) floods the trace stream. return "" } return string(out) From cd005ef9a999c6a8c00fb80b6de0d572308530ff Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:13:52 +0200 Subject: [PATCH 012/151] Add CopyRect detection and emission for tile-aligned moves --- client/vnc/server/copyrect.go | 189 +++++++++++++++++++++++++++++ client/vnc/server/copyrect_test.go | 160 ++++++++++++++++++++++++ client/vnc/server/rfb.go | 43 +++++-- client/vnc/server/session.go | 90 +++++++++++--- 4 files changed, 458 insertions(+), 24 deletions(-) create mode 100644 client/vnc/server/copyrect.go create mode 100644 client/vnc/server/copyrect_test.go diff --git a/client/vnc/server/copyrect.go b/client/vnc/server/copyrect.go new file mode 100644 index 00000000000..b0356a37e82 --- /dev/null +++ b/client/vnc/server/copyrect.go @@ -0,0 +1,189 @@ +package server + +import ( + "hash/maphash" + "image" +) + +// copyRectDetector finds tiles in the current frame that match the content +// of some tile-aligned region of the previous frame, so we can emit them as +// CopyRect rectangles (16 wire bytes) instead of re-encoding the pixels. +// +// The detector keeps two structures: +// - tileHash, a flat slice of one hash per tile-aligned position, used as +// the source of truth for the previous frame's tile content. +// - prevTiles, a hash → position lookup used during findTileMatch. +// +// updateDirty rehashes only the tiles that changed this frame, so the +// steady-state cost is proportional to the dirty set, not the framebuffer. +// A full rebuild from scratch is only done on the first frame or when the +// detector has not yet been initialized for the current resolution. +// +// Limitations: +// - Only tile-aligned source positions are considered. Sub-tile-aligned +// moves (e.g. window dragged by 7 pixels) are not detected. This still +// covers the common case of vertical/horizontal scrolling, which always +// produces tile-aligned matches at the tile granularity. +// - 64-bit maphash collisions are assumed not to happen. The probability +// for any single frame's hash universe is ~2^-32 * tileCount² which is +// vanishingly small at typical resolutions; if we ever observe one we +// can fall back to a full memcmp verification. +type copyRectDetector struct { + seed maphash.Seed + tileSize int + w, h int + cols, rows int + // tileHash[ty*cols + tx] is the current hash of the tile at (tx, ty) + // in the previous frame. Lookup uses this to detect stale prevTiles + // entries — incremental updates may leave hash→pos entries pointing + // at a tile whose content has since changed. + tileHash []uint64 + // prevTiles maps a tile hash to a (x, y) origin in the previous frame. + prevTiles map[uint64][2]int + // hash is reused across hash computations to keep the per-tile lookup + // path allocation-free. + hash maphash.Hash +} + +func newCopyRectDetector(tileSize int) *copyRectDetector { + d := ©RectDetector{ + seed: maphash.MakeSeed(), + tileSize: tileSize, + prevTiles: make(map[uint64][2]int), + } + d.hash.SetSeed(d.seed) + return d +} + +// resize ensures the per-tile tables match the given framebuffer size. +// Called from rebuild before each full hash sweep. +func (d *copyRectDetector) resize(w, h int) { + if d.w == w && d.h == h && d.tileHash != nil { + return + } + d.w, d.h = w, h + d.cols = w / d.tileSize + d.rows = h / d.tileSize + d.tileHash = make([]uint64, d.cols*d.rows) +} + +// hashTile computes the 64-bit maphash of one tile-aligned tile of frame. +func (d *copyRectDetector) hashTile(frame *image.RGBA, tx, ty int) uint64 { + d.hash.Reset() + ts := d.tileSize + stride := frame.Stride + rowBytes := ts * 4 + base := ty*stride + tx*4 + for row := 0; row < ts; row++ { + off := base + row*stride + _, _ = d.hash.Write(frame.Pix[off : off+rowBytes]) + } + return d.hash.Sum64() +} + +// rebuild discards everything and rehashes the whole frame. O(w*h). Use +// for the first frame or after the detector has been resized. Steady-state +// updates should go through updateDirty instead. +func (d *copyRectDetector) rebuild(frame *image.RGBA, w, h int) { + d.resize(w, h) + if d.prevTiles == nil { + d.prevTiles = make(map[uint64][2]int) + } else { + clear(d.prevTiles) + } + ts := d.tileSize + for ty := 0; ty+ts <= h; ty += ts { + for tx := 0; tx+ts <= w; tx += ts { + sum := d.hashTile(frame, tx, ty) + d.tileHash[(ty/ts)*d.cols+(tx/ts)] = sum + if _, exists := d.prevTiles[sum]; !exists { + d.prevTiles[sum] = [2]int{tx, ty} + } + } + } +} + +// updateDirty rehashes only the tiles named in dirty (each entry is +// [x, y, w, h] with w and h equal to tileSize). O(len(dirty)) work, which +// in the common case is a tiny fraction of the whole framebuffer. +// +// The prevTiles map is replaced on collision rather than first-wins so a +// newly-hashed tile claims the slot. Old, stale entries pointing at tiles +// that no longer carry that hash are filtered at lookup time via tileHash. +func (d *copyRectDetector) updateDirty(frame *image.RGBA, w, h int, dirty [][4]int) { + if d.w != w || d.h != h || d.tileHash == nil { + d.rebuild(frame, w, h) + return + } + ts := d.tileSize + for _, r := range dirty { + if r[2] != ts || r[3] != ts { + continue + } + tx, ty := r[0], r[1] + if tx+ts > w || ty+ts > h { + continue + } + sum := d.hashTile(frame, tx, ty) + d.tileHash[(ty/ts)*d.cols+(tx/ts)] = sum + // Latest-wins on collision: ensures the most recent owner of this + // hash is the one we'll return on lookup. The previous owner's + // entry, if any, gets shadowed; if its content has changed it's + // stale anyway and findTileMatch's verification will skip it. + d.prevTiles[sum] = [2]int{tx, ty} + } +} + +// findTileMatch hashes the current-frame tile at (dstX, dstY) and looks up +// its hash in the previous-frame map. Returns (srcX, srcY, true) when a +// matching tile-aligned tile exists at a different position whose stored +// hash still equals the requested hash (so the result is not stale). +func (d *copyRectDetector) findTileMatch(cur *image.RGBA, dstX, dstY int) (int, int, bool) { + if len(d.prevTiles) == 0 || d.tileHash == nil { + return 0, 0, false + } + ts := d.tileSize + if dstX+ts > cur.Rect.Dx() || dstY+ts > cur.Rect.Dy() { + return 0, 0, false + } + sum := d.hashTile(cur, dstX, dstY) + pos, ok := d.prevTiles[sum] + if !ok { + return 0, 0, false + } + if pos[0] == dstX && pos[1] == dstY { + return 0, 0, false + } + // Reject stale entries: the position the map points at must still + // carry the same hash according to our per-tile array. + if d.tileHash[(pos[1]/ts)*d.cols+(pos[0]/ts)] != sum { + return 0, 0, false + } + return pos[0], pos[1], true +} + +// extractCopyRectTiles examines the diff-produced (per-tile) dirty list and +// pulls out any tiles whose current-frame content matches a prev-frame tile +// at a different position. Returns the CopyRect candidates and the residual +// dirty tiles that still need pixel encoding. +type copyRectMove struct { + srcX, srcY int + dstX, dstY int +} + +func (d *copyRectDetector) extractCopyRectTiles(cur *image.RGBA, dirtyTiles [][4]int) (moves []copyRectMove, remaining [][4]int) { + ts := d.tileSize + remaining = dirtyTiles[:0:cap(dirtyTiles)] + for _, r := range dirtyTiles { + if r[2] == ts && r[3] == ts { + if sx, sy, ok := d.findTileMatch(cur, r[0], r[1]); ok { + moves = append(moves, copyRectMove{ + srcX: sx, srcY: sy, dstX: r[0], dstY: r[1], + }) + continue + } + } + remaining = append(remaining, r) + } + return moves, remaining +} diff --git a/client/vnc/server/copyrect_test.go b/client/vnc/server/copyrect_test.go new file mode 100644 index 00000000000..8b5691b56c3 --- /dev/null +++ b/client/vnc/server/copyrect_test.go @@ -0,0 +1,160 @@ +package server + +import ( + "image" + "testing" +) + +// fillTile paints a tileSize×tileSize block of img at (x,y) with the colour +// derived from (r,g,b) so the test can construct distinct-content tiles. +func fillTile(img *image.RGBA, x, y, ts int, r, g, b byte) { + for row := 0; row < ts; row++ { + off := (y+row)*img.Stride + x*4 + for col := 0; col < ts; col++ { + img.Pix[off+col*4+0] = r + img.Pix[off+col*4+1] = g + img.Pix[off+col*4+2] = b + img.Pix[off+col*4+3] = 0xff + } + } +} + +// copyTile copies a tileSize×tileSize block from src(sx,sy) to dst(dx,dy). +func copyTile(dst, src *image.RGBA, sx, sy, dx, dy, ts int) { + for row := 0; row < ts; row++ { + srcOff := (sy+row)*src.Stride + sx*4 + dstOff := (dy+row)*dst.Stride + dx*4 + copy(dst.Pix[dstOff:dstOff+ts*4], src.Pix[srcOff:srcOff+ts*4]) + } +} + +func TestCopyRectDetector_DetectsVerticalScroll(t *testing.T) { + const w, h = 256, 192 // 4×3 tiles at 64px + const ts = 64 + + prev := image.NewRGBA(image.Rect(0, 0, w, h)) + cur := image.NewRGBA(image.Rect(0, 0, w, h)) + + // prev: 12 tiles each with a unique colour. + for ty := 0; ty < 3; ty++ { + for tx := 0; tx < 4; tx++ { + fillTile(prev, tx*ts, ty*ts, ts, byte(tx*40), byte(ty*60), 0x80) + } + } + // cur: simulate a single-tile-row scroll upward — every tile copied from + // the row below in prev, top row is new content. + for ty := 0; ty < 2; ty++ { + for tx := 0; tx < 4; tx++ { + copyTile(cur, prev, tx*ts, (ty+1)*ts, tx*ts, ty*ts, ts) + } + } + // Bottom row of cur: new colour, not a match. + for tx := 0; tx < 4; tx++ { + fillTile(cur, tx*ts, 2*ts, ts, 0xff, 0xff, 0xff) + } + + d := newCopyRectDetector(ts) + d.rebuild(prev, w, h) + + tiles := diffTiles(prev, cur, w, h, ts) + moves, remaining := d.extractCopyRectTiles(cur, tiles) + + // Expect 8 CopyRect moves (top two rows) and 4 residual tiles (bottom row). + if len(moves) != 8 { + t.Fatalf("moves: want 8, got %d", len(moves)) + } + if len(remaining) != 4 { + t.Fatalf("remaining: want 4, got %d", len(remaining)) + } + // Spot-check one move: cur (0, 0) should map to prev (0, 64). + var found bool + for _, m := range moves { + if m.dstX == 0 && m.dstY == 0 { + if m.srcX != 0 || m.srcY != ts { + t.Fatalf("move at (0,0): src=(%d,%d), want (0,%d)", m.srcX, m.srcY, ts) + } + found = true + } + } + if !found { + t.Fatalf("no move for dst (0,0)") + } +} + +func TestCopyRectDetector_RejectsSelfMatch(t *testing.T) { + const w, h = 128, 128 + const ts = 64 + + prev := image.NewRGBA(image.Rect(0, 0, w, h)) + cur := image.NewRGBA(image.Rect(0, 0, w, h)) + + // prev: 4 tiles, all unique + fillTile(prev, 0, 0, ts, 0x10, 0x20, 0x30) + fillTile(prev, ts, 0, ts, 0x40, 0x50, 0x60) + fillTile(prev, 0, ts, ts, 0x70, 0x80, 0x90) + fillTile(prev, ts, ts, ts, 0xa0, 0xb0, 0xc0) + + // cur: tile (0,0) unchanged, others changed but content same as prev's (0,0). + fillTile(cur, 0, 0, ts, 0x10, 0x20, 0x30) // self-match + fillTile(cur, ts, 0, ts, 0xff, 0xff, 0xff) + fillTile(cur, 0, ts, ts, 0xff, 0xff, 0xff) + fillTile(cur, ts, ts, ts, 0xff, 0xff, 0xff) + + d := newCopyRectDetector(ts) + d.rebuild(prev, w, h) + + // Tile (0,0) is not in the dirty list (it's unchanged) so it should not + // produce a move even though its hash matches prev (0,0). + tiles := diffTiles(prev, cur, w, h, ts) + moves, _ := d.extractCopyRectTiles(cur, tiles) + for _, m := range moves { + if m.dstX == 0 && m.dstY == 0 { + t.Fatalf("unexpected move at (0,0)") + } + } +} + +func TestCopyRectDetector_PassThroughWhenNoMatch(t *testing.T) { + const w, h = 64, 64 + const ts = 64 + + prev := image.NewRGBA(image.Rect(0, 0, w, h)) + cur := image.NewRGBA(image.Rect(0, 0, w, h)) + fillTile(prev, 0, 0, ts, 0x11, 0x22, 0x33) + fillTile(cur, 0, 0, ts, 0xaa, 0xbb, 0xcc) // wholly different + + d := newCopyRectDetector(ts) + d.rebuild(prev, w, h) + tiles := diffTiles(prev, cur, w, h, ts) + moves, remaining := d.extractCopyRectTiles(cur, tiles) + + if len(moves) != 0 { + t.Fatalf("expected 0 moves, got %d", len(moves)) + } + if len(remaining) != 1 { + t.Fatalf("expected 1 residual tile, got %d", len(remaining)) + } +} + +func TestEncodeCopyRectBody_Layout(t *testing.T) { + got := encodeCopyRectBody(100, 200, 300, 400, 64, 48) + if len(got) != 16 { + t.Fatalf("CopyRect body length: want 16, got %d", len(got)) + } + // Dest position + if got[0] != 0x01 || got[1] != 0x2c || got[2] != 0x01 || got[3] != 0x90 { + t.Fatalf("bad dest bytes: % x", got[0:4]) + } + // Width, height + if got[4] != 0 || got[5] != 64 || got[6] != 0 || got[7] != 48 { + t.Fatalf("bad size bytes: % x", got[4:8]) + } + // Encoding = 1 + if got[11] != 0x01 { + t.Fatalf("bad encoding byte: 0x%02x", got[11]) + } + // Source position + if got[12] != 0 || got[13] != 100 || got[14] != 0 || got[15] != 200 { + t.Fatalf("bad src bytes: % x", got[12:16]) + } +} diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 4822c0abf37..affc8c0a8fc 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -46,10 +46,11 @@ const ( serverCutText = 3 // Encoding types. - encRaw = 0 - encHextile = 5 - encZlib = 6 - encTight = 7 + encRaw = 0 + encCopyRect = 1 + encHextile = 5 + encZlib = 6 + encTight = 7 // Tight compression-control byte top nibble. Stream-reset bits 0-3 // (one per zlib stream) are unused while we run a single stream. @@ -140,6 +141,22 @@ func parsePixelFormat(pf []byte) clientPixelFormat { } } +// encodeCopyRectBody emits the per-rect payload for a CopyRect rectangle: +// the 12-byte rect header (dst position + size + encoding=1) plus a 4-byte +// source position. Used inside multi-rect FramebufferUpdate messages, so +// the 4-byte FU header is the caller's responsibility. +func encodeCopyRectBody(srcX, srcY, dstX, dstY, w, h int) []byte { + buf := make([]byte, 12+4) + binary.BigEndian.PutUint16(buf[0:2], uint16(dstX)) + binary.BigEndian.PutUint16(buf[2:4], uint16(dstY)) + binary.BigEndian.PutUint16(buf[4:6], uint16(w)) + binary.BigEndian.PutUint16(buf[6:8], uint16(h)) + binary.BigEndian.PutUint32(buf[8:12], uint32(encCopyRect)) + binary.BigEndian.PutUint16(buf[12:14], uint16(srcX)) + binary.BigEndian.PutUint16(buf[14:16], uint16(srcY)) + return buf +} + // encodeRawRect encodes a framebuffer region as a raw RFB rectangle. // The returned buffer includes the FramebufferUpdate header (1 rectangle). func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte { @@ -311,13 +328,14 @@ func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zl return buf } -// diffRects compares two RGBA images and returns a list of dirty rectangles. -// Divides the screen into tiles and checks each for changes. -func diffRects(prev, cur *image.RGBA, w, h, tileSize int) [][4]int { +// diffTiles compares two RGBA images and returns a tile-ordered list of +// dirty tiles, one entry per tile. Tile order is top-to-bottom, left-to- +// right within each row. The caller decides whether to coalesce or hand +// the list off to the CopyRect detector first. +func diffTiles(prev, cur *image.RGBA, w, h, tileSize int) [][4]int { if prev == nil { return [][4]int{{0, 0, w, h}} } - var rects [][4]int for ty := 0; ty < h; ty += tileSize { th := min(tileSize, h-ty) @@ -328,7 +346,14 @@ func diffRects(prev, cur *image.RGBA, w, h, tileSize int) [][4]int { } } } - return coalesceRects(rects) + return rects +} + +// diffRects is the legacy convenience: diff then coalesce. Used by paths +// that don't go through the CopyRect detector and by tests that exercise +// the diff-plus-coalesce pipeline as one unit. +func diffRects(prev, cur *image.RGBA, w, h, tileSize int) [][4]int { + return coalesceRects(diffTiles(prev, cur, w, h, tileSize)) } // coalesceRects merges adjacent dirty tiles into larger rectangles to cut diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 3b09bda0187..9fb21eeb9cf 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -47,13 +47,15 @@ type session struct { // messageLoop writes these on SetPixelFormat/SetEncodings, which RFB // clients may send at any time after the handshake, while encoderLoop // reads them on every frame. - encMu sync.RWMutex - pf clientPixelFormat - useZlib bool - useHextile bool - useTight bool - zlib *zlibState - tight *tightState + encMu sync.RWMutex + pf clientPixelFormat + useZlib bool + useHextile bool + useTight bool + useCopyRect bool + zlib *zlibState + tight *tightState + copyRectDet *copyRectDetector // prevFrame, curFrame and idleFrames live on the encoder goroutine and // must not be touched elsewhere. curFrame holds a session-owned copy of // the capturer's latest frame so the encoder works on a stable buffer @@ -319,6 +321,12 @@ func (s *session) handleSetEncodings() error { for i := range int(numEnc) { enc := int32(binary.BigEndian.Uint32(buf[i*4 : i*4+4])) switch enc { + case encCopyRect: + s.useCopyRect = true + if s.copyRectDet == nil { + s.copyRectDet = newCopyRectDetector(tileSize) + } + encs = append(encs, "copyrect") case encZlib: s.useZlib = true if s.zlib == nil { @@ -410,8 +418,8 @@ func (s *session) processFBRequest(req fbRequest) error { } if req.incremental && s.prevFrame != nil { - rects := diffRects(s.prevFrame, img, s.serverW, s.serverH, tileSize) - if len(rects) == 0 { + tiles := diffTiles(s.prevFrame, img, s.serverW, s.serverH, tileSize) + if len(tiles) == 0 { // Nothing changed. Back off briefly before responding to reduce // CPU usage when the screen is static. The client re-requests // immediately after receiving our empty response, so without @@ -423,17 +431,33 @@ func (s *session) processFBRequest(req fbRequest) error { return s.sendEmptyUpdate() } s.idleFrames = 0 - if s.shouldPromoteToFullFrame(rects) { + + // Snapshot the dirty set before extractCopyRectTiles consumes it. + // extract mutates in place, so without the copy we lose the + // move-destination positions needed to incrementally update the + // CopyRect index after the swap. + dirty := make([][4]int, len(tiles)) + copy(dirty, tiles) + + var moves []copyRectMove + if s.useCopyRect && s.copyRectDet != nil { + moves, tiles = s.copyRectDet.extractCopyRectTiles(img, tiles) + } + + rects := coalesceRects(tiles) + if s.shouldPromoteToFullFrame(rects) && len(moves) == 0 { if err := s.sendFullUpdate(img); err != nil { return err } s.swapPrevCur() + s.refreshCopyRectIndex() return nil } - if err := s.sendDirtyRects(img, rects); err != nil { + if err := s.sendDirtyAndMoves(img, moves, rects); err != nil { return err } s.swapPrevCur() + s.updateCopyRectIndex(dirty) return nil } @@ -443,9 +467,30 @@ func (s *session) processFBRequest(req fbRequest) error { return err } s.swapPrevCur() + s.refreshCopyRectIndex() return nil } +// refreshCopyRectIndex does a full hash sweep of the just-swapped prevFrame. +// Used after full-frame sends, where we don't have a per-tile dirty list to +// drive an incremental update. +func (s *session) refreshCopyRectIndex() { + if s.copyRectDet == nil || s.prevFrame == nil { + return + } + s.copyRectDet.rebuild(s.prevFrame, s.serverW, s.serverH) +} + +// updateCopyRectIndex incrementally updates the CopyRect detector's hash +// tables for the tiles that just changed. On first use (or after resize) +// updateDirty internally falls back to a full rebuild. +func (s *session) updateCopyRectIndex(dirty [][4]int) { + if s.copyRectDet == nil || s.prevFrame == nil { + return + } + s.copyRectDet.updateDirty(s.prevFrame, s.serverW, s.serverH, dirty) +} + // captureFrame returns a session-owned frame for this encode cycle. // Capturers that implement captureIntoer (Linux X11, macOS) write directly // into curFrame, saving a per-frame full-screen memcpy. Capturers that @@ -529,12 +574,19 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { return err } -func (s *session) sendDirtyRects(img *image.RGBA, rects [][4]int) error { - // Build a multi-rectangle FramebufferUpdate. - // Header: type(1) + padding(1) + numRects(2) +// sendDirtyAndMoves writes one FramebufferUpdate combining CopyRect moves +// (cheap, 16 bytes each) and pixel-encoded dirty rects. Moves come first so +// their source tiles are read from the client's pre-update framebuffer state, +// before any subsequent rect overwrites them. +func (s *session) sendDirtyAndMoves(img *image.RGBA, moves []copyRectMove, rects [][4]int) error { + if len(moves) == 0 && len(rects) == 0 { + return nil + } + + total := len(moves) + len(rects) header := make([]byte, 4) header[0] = serverFramebufferUpdate - binary.BigEndian.PutUint16(header[2:4], uint16(len(rects))) + binary.BigEndian.PutUint16(header[2:4], uint16(total)) s.writeMu.Lock() defer s.writeMu.Unlock() @@ -543,6 +595,14 @@ func (s *session) sendDirtyRects(img *image.RGBA, rects [][4]int) error { return err } + ts := tileSize + for _, m := range moves { + body := encodeCopyRectBody(m.srcX, m.srcY, m.dstX, m.dstY, ts, ts) + if _, err := s.conn.Write(body); err != nil { + return err + } + } + for _, r := range rects { x, y, w, h := r[0], r[1], r[2], r[3] rectBuf := s.encodeTile(img, x, y, w, h) From 047cc958b55d0a931c6a8e870951f096b630949d Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:23:34 +0200 Subject: [PATCH 013/151] Throttle capture-failure log to once per 5s while capturer is down --- client/vnc/server/session.go | 40 ++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 9fb21eeb9cf..1185360f301 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -64,6 +64,12 @@ type session struct { curFrame *image.RGBA idleFrames int + // captureErrLast throttles "capture (transient)" logs while the + // capturer is in a sustained failure state (e.g. X server died but a + // noVNC tab is still open). Owned by the encoder goroutine. + captureErrLast time.Time + captureErrSeen bool + // encodeCh carries framebuffer-update requests from the read loop to the // encoder goroutine. Buffered size 1: RFB clients have one outstanding // request at a time, so a new request always replaces any pending one. @@ -409,13 +415,16 @@ func (s *session) processFBRequest(req fbRequest) error { // Capture failures are transient on Windows: a Ctrl+Alt+Del or // sign-out switches the OS to the secure desktop, and the DXGI // duplicator on the previous desktop returns an error until the - // capturer reattaches on the new desktop. Don't tear down the - // session. Back off briefly and reply with an empty update so - // the client keeps re-requesting. - s.log.Debugf("capture (transient): %v", err) + // capturer reattaches on the new desktop. On Linux the X server + // behind a virtual session may exit and the capturer reports + // "unavailable" on every retry tick. Don't tear down the session + // and don't spam the log: emit one line on the first failure, then + // throttle further "still failing" lines to once per 5 s. + s.captureErrorLog(err) time.Sleep(100 * time.Millisecond) return s.sendEmptyUpdate() } + s.captureRecovered() if req.incremental && s.prevFrame != nil { tiles := diffTiles(s.prevFrame, img, s.serverW, s.serverH, tileSize) @@ -471,6 +480,29 @@ func (s *session) processFBRequest(req fbRequest) error { return nil } +// captureErrorLog emits one log line on the first failure after success, +// then at most once every captureErrThrottle while the capturer keeps +// failing. The "recovered" transition is logged once when err is nil and +// captureErrSeen was set. +func (s *session) captureErrorLog(err error) { + const captureErrThrottle = 5 * time.Second + now := time.Now() + if !s.captureErrSeen || now.Sub(s.captureErrLast) >= captureErrThrottle { + s.log.Debugf("capture (transient): %v", err) + s.captureErrLast = now + } + s.captureErrSeen = true +} + +// captureRecovered emits a one-shot debug line when capture works again +// after a failure streak. Called by the success paths. +func (s *session) captureRecovered() { + if s.captureErrSeen { + s.log.Debugf("capture recovered") + s.captureErrSeen = false + } +} + // refreshCopyRectIndex does a full hash sweep of the just-swapped prevFrame. // Used after full-frame sends, where we don't have a per-tile dirty list to // drive an incremental update. From e75948753a66dd363fcf184b97ca6e601cb02512 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:28:40 +0200 Subject: [PATCH 014/151] Prompt for macOS Accessibility and Screen Recording at VNC enable time --- client/internal/engine_vnc_darwin.go | 5 ++ client/vnc/server/capture_darwin.go | 21 ++++++ client/vnc/server/input_darwin.go | 105 ++++++++++++++++++++++++--- 3 files changed, 120 insertions(+), 11 deletions(-) diff --git a/client/internal/engine_vnc_darwin.go b/client/internal/engine_vnc_darwin.go index 7efe6f064f3..0f182cdb065 100644 --- a/client/internal/engine_vnc_darwin.go +++ b/client/internal/engine_vnc_darwin.go @@ -10,6 +10,11 @@ import ( func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { capturer := vncserver.NewMacPoller() + // Prompt for Screen Recording at server-enable time rather than first + // client-connect. The native prompt is far easier for users to act on + // in the moment they toggled VNC on than later when "the screen looks + // like wallpaper" would otherwise be the only clue. + vncserver.PrimeScreenCapturePermission() injector, err := vncserver.NewMacInputInjector() if err != nil { log.Debugf("VNC: macOS input injector: %v", err) diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index ac32449115a..2efe02f0807 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -93,6 +93,27 @@ type CGCapturer struct { hasHash bool } +// PrimeScreenCapturePermission triggers the macOS Screen Recording +// permission probe (and prompt, if not granted) without creating a full +// capturer. The platform wiring calls this at VNC-server enable time so +// the user sees the prompt the moment they turn the feature on, rather +// than on first-client-connect when the cause may not be obvious. +func PrimeScreenCapturePermission() { + initDarwinCapture() + if !darwinCaptureReady { + return + } + if cgPreflightScreenCaptureAccess == nil || cgPreflightScreenCaptureAccess() { + return + } + if cgRequestScreenCaptureAccess != nil { + cgRequestScreenCaptureAccess() + } + openPrivacyPane("Privacy_ScreenCapture") + log.Warn("Screen Recording permission not granted. Approve the prompt " + + "or grant in System Settings > Privacy & Security > Screen Recording.") +} + // NewCGCapturer creates a screen capturer for the main display. func NewCGCapturer() (*CGCapturer, error) { initDarwinCapture() diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 2982c71d9bf..1b830035d3c 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -7,6 +7,7 @@ import ( "os/exec" "strings" "sync" + "unsafe" "github.com/ebitengine/purego" log "github.com/sirupsen/logrus" @@ -54,6 +55,23 @@ var ( cgEventCreateScrollWheelEventAddr uintptr axIsProcessTrusted func() bool + // axIsProcessTrustedWithOptions takes a CFDictionary; when the dict's + // kAXTrustedCheckOptionPrompt key is true, macOS shows the native + // Accessibility prompt with an "Open System Settings" button the + // first time the process asks. The bare AXIsProcessTrusted variant is + // a silent check that never prompts. + axIsProcessTrustedWithOptions func(uintptr) bool + // cfDictionaryCreate builds the options dictionary above. + cfDictionaryCreate func(uintptr, *uintptr, *uintptr, int64, uintptr, uintptr) uintptr + // cfBooleanTrue is the global CF boolean we cache from a Dlsym lookup. + cfBooleanTrue uintptr + // axTrustedCheckOptionPromptCFStr is the option key for the dict. + axTrustedCheckOptionPromptCFStr uintptr + // kCFTypeDictionaryKey/Value CallBacks: standard CF retain/release + // callback tables. Required so the dict properly manages refcounts on + // the CFString key and CFBoolean value. + kCFTypeDictionaryKeyCallBacksAddr uintptr + kCFTypeDictionaryValueCallBacksAddr uintptr // IOKit power-management bindings used to wake the display and inhibit // idle sleep while a VNC client is driving input. @@ -100,14 +118,49 @@ func initDarwinInput() { if sym, err := purego.Dlsym(ax, "AXIsProcessTrusted"); err == nil { purego.RegisterFunc(&axIsProcessTrusted, sym) } + if sym, err := purego.Dlsym(ax, "AXIsProcessTrustedWithOptions"); err == nil { + purego.RegisterFunc(&axIsProcessTrustedWithOptions, sym) + } } + // initPowerAssertions registers cfStringCreateWithCString, which + // initCFDictionarySymbols then uses to build the AX prompt key. initPowerAssertions() + initCFDictionarySymbols() darwinInputReady = true }) } +// initCFDictionarySymbols loads the CF symbols needed to build the +// options dictionary for AXIsProcessTrustedWithOptions. Best-effort: +// failure here just leaves axIsProcessTrustedWithOptions unusable and we +// fall back to the silent check. +func initCFDictionarySymbols() { + cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + log.Debugf("load CoreFoundation for AX prompt dict: %v", err) + return + } + if sym, err := purego.Dlsym(cf, "CFDictionaryCreate"); err == nil { + purego.RegisterFunc(&cfDictionaryCreate, sym) + } + if sym, err := purego.Dlsym(cf, "kCFTypeDictionaryKeyCallBacks"); err == nil { + kCFTypeDictionaryKeyCallBacksAddr = sym + } + if sym, err := purego.Dlsym(cf, "kCFTypeDictionaryValueCallBacks"); err == nil { + kCFTypeDictionaryValueCallBacksAddr = sym + } + if sym, err := purego.Dlsym(cf, "kCFBooleanTrue"); err == nil { + // kCFBooleanTrue is a pointer-to-pointer (CFBooleanRef stored at the + // symbol address). Dereference once to get the actual CFBoolean. + cfBooleanTrue = *(*uintptr)(unsafe.Pointer(sym)) + } + if cfStringCreateWithCString != nil { + axTrustedCheckOptionPromptCFStr = cfStringCreateWithCString(0, "AXTrustedCheckOptionPrompt", kCFStringEncodingUTF8) + } +} + func initPowerAssertions() { iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL) if err != nil { @@ -234,23 +287,53 @@ func NewMacInputInjector() (*MacInputInjector, error) { return m, nil } -// checkMacPermissions warns and opens the Privacy pane if Accessibility is -// missing. Uses AXIsProcessTrusted which returns immediately; the previous -// osascript probe blocked for 120s (AppleEvent timeout) when access was -// denied, which delayed VNC server startup past client deadlines. +// checkMacPermissions probes Accessibility access. Prefers the prompting +// variant of AXIsProcessTrusted: when the process is not yet trusted, +// macOS shows its native "would like to control your computer" dialog +// with an "Open System Settings" button. The silent variant is the +// fallback when the prompting symbol or its CF dictionary plumbing +// couldn't be loaded. func checkMacPermissions() { - if axIsProcessTrusted != nil && !axIsProcessTrusted() { - openPrivacyPane("Privacy_Accessibility") + if !axProcessIsTrusted() { log.Warn("Accessibility permission not granted. Input injection will not work. " + - "Opened System Settings > Privacy & Security > Accessibility; enable netbird.") + "Approve the prompt or grant in System Settings > Privacy & Security > Accessibility.") + openPrivacyPane("Privacy_Accessibility") } +} - log.Info("Screen Recording permission is required for screen capture. " + - "If the screen appears black, grant in System Settings > Privacy & Security > Screen Recording.") +// axProcessIsTrusted asks macOS whether netbird has Accessibility access, +// and triggers the native prompt the first time when not trusted. Returns +// the current trust status either way. +func axProcessIsTrusted() bool { + if axIsProcessTrustedWithOptions != nil && + cfDictionaryCreate != nil && + axTrustedCheckOptionPromptCFStr != 0 && + cfBooleanTrue != 0 && + kCFTypeDictionaryKeyCallBacksAddr != 0 && + kCFTypeDictionaryValueCallBacksAddr != 0 { + keys := [1]uintptr{axTrustedCheckOptionPromptCFStr} + values := [1]uintptr{cfBooleanTrue} + dict := cfDictionaryCreate(0, &keys[0], &values[0], 1, + kCFTypeDictionaryKeyCallBacksAddr, + kCFTypeDictionaryValueCallBacksAddr) + if dict != 0 { + return axIsProcessTrustedWithOptions(dict) + } + } + if axIsProcessTrusted != nil { + return axIsProcessTrusted() + } + // Symbol load failed entirely. Assume trusted so we don't spam the + // log every cycle; capture/inject calls will report concrete errors + // if access really is missing. + return true } -// openPrivacyPane opens the given Privacy pane in System Settings so the user -// can toggle the permission without navigating manually. +// openPrivacyPane opens the relevant pane of System Settings so the user +// can toggle the permission without navigating manually. The +// x-apple.systempreferences URL scheme works on every macOS release from +// 10.10 onward; the per-pane anchor (Privacy_Accessibility, Privacy_ScreenCapture) +// is what System Settings/Preferences uses to land on the right row. func openPrivacyPane(pane string) { url := "x-apple.systempreferences:com.apple.preference.security?" + pane if err := exec.Command("open", url).Start(); err != nil { From db5b6cfbb71e860cc0cf9e9f9b7b960084928308 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:34:22 +0200 Subject: [PATCH 015/151] Add DesktopSize, DesktopName, LastRect pseudo-encodings with resize detection --- client/vnc/server/pseudo_encodings_test.go | 57 +++++++++ client/vnc/server/rfb.go | 54 +++++++++ client/vnc/server/session.go | 132 +++++++++++++++++++-- 3 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 client/vnc/server/pseudo_encodings_test.go diff --git a/client/vnc/server/pseudo_encodings_test.go b/client/vnc/server/pseudo_encodings_test.go new file mode 100644 index 00000000000..a319227c586 --- /dev/null +++ b/client/vnc/server/pseudo_encodings_test.go @@ -0,0 +1,57 @@ +package server + +import "testing" + +func TestEncodeDesktopSizeBody(t *testing.T) { + got := encodeDesktopSizeBody(1920, 1080) + if len(got) != 12 { + t.Fatalf("DesktopSize body length: want 12, got %d", len(got)) + } + if got[0] != 0 || got[1] != 0 || got[2] != 0 || got[3] != 0 { + t.Fatalf("DesktopSize: x and y must be zero; got % x", got[0:4]) + } + if got[4] != 0x07 || got[5] != 0x80 { + t.Fatalf("DesktopSize: width should be 1920 (0x0780); got % x", got[4:6]) + } + if got[6] != 0x04 || got[7] != 0x38 { + t.Fatalf("DesktopSize: height should be 1080 (0x0438); got % x", got[6:8]) + } + // Encoding = -223 → 0xFFFFFF21 in two's complement big-endian. + if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFF || got[11] != 0x21 { + t.Fatalf("DesktopSize: encoding bytes wrong: % x", got[8:12]) + } +} + +func TestEncodeDesktopNameBody(t *testing.T) { + name := "vma@debian3" + got := encodeDesktopNameBody(name) + if len(got) != 12+4+len(name) { + t.Fatalf("DesktopName body length: want %d, got %d", 12+4+len(name), len(got)) + } + // Encoding = -307 → 0xFFFFFECD. + if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFE || got[11] != 0xCD { + t.Fatalf("DesktopName: encoding bytes wrong: % x", got[8:12]) + } + if got[12] != 0 || got[13] != 0 || got[14] != 0 || got[15] != byte(len(name)) { + t.Fatalf("DesktopName: name length prefix wrong: % x", got[12:16]) + } + if string(got[16:]) != name { + t.Fatalf("DesktopName: name body wrong: %q", got[16:]) + } +} + +func TestEncodeLastRectBody(t *testing.T) { + got := encodeLastRectBody() + if len(got) != 12 { + t.Fatalf("LastRect body length: want 12, got %d", len(got)) + } + for i := 0; i < 8; i++ { + if got[i] != 0 { + t.Fatalf("LastRect: header bytes 0..7 must be zero; got byte %d = 0x%02x", i, got[i]) + } + } + // Encoding = -224 → 0xFFFFFF20. + if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFF || got[11] != 0x20 { + t.Fatalf("LastRect: encoding bytes wrong: % x", got[8:12]) + } +} diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index affc8c0a8fc..f9a3af485d8 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -52,6 +52,14 @@ const ( encZlib = 6 encTight = 7 + // Pseudo-encodings carried over wire as rects with a negative + // encoding value. The client advertises supported optional protocol + // extensions by listing these in SetEncodings. + pseudoEncDesktopSize = -223 + pseudoEncLastRect = -224 + pseudoEncDesktopName = -307 + pseudoEncExtendedDesktopSize = -308 + // Tight compression-control byte top nibble. Stream-reset bits 0-3 // (one per zlib stream) are unused while we run a single stream. tightFillSubenc = 0x80 @@ -157,6 +165,52 @@ func encodeCopyRectBody(srcX, srcY, dstX, dstY, w, h int) []byte { return buf } +// encodeDesktopSizeBody emits a DesktopSize pseudo-encoded rectangle. The +// "rect" carries no pixel data: x and y are zero, w and h are the new +// framebuffer dimensions, and encoding=-223 signals to the client that the +// framebuffer was resized. Clients reallocate their backing buffer and +// expect a full update at the new size to follow. +func encodeDesktopSizeBody(w, h int) []byte { + buf := make([]byte, 12) + binary.BigEndian.PutUint16(buf[0:2], 0) + binary.BigEndian.PutUint16(buf[2:4], 0) + binary.BigEndian.PutUint16(buf[4:6], uint16(w)) + binary.BigEndian.PutUint16(buf[6:8], uint16(h)) + enc := int32(pseudoEncDesktopSize) + binary.BigEndian.PutUint32(buf[8:12], uint32(enc)) + return buf +} + +// encodeDesktopNameBody emits a DesktopName pseudo-encoded rectangle. The +// rect header is all zeros and encoding=-307; the body is a 4-byte +// big-endian length followed by the UTF-8 name. Clients update their +// window title or label without reconnecting. +func encodeDesktopNameBody(name string) []byte { + nameBytes := []byte(name) + buf := make([]byte, 12+4+len(nameBytes)) + binary.BigEndian.PutUint16(buf[0:2], 0) + binary.BigEndian.PutUint16(buf[2:4], 0) + binary.BigEndian.PutUint16(buf[4:6], 0) + binary.BigEndian.PutUint16(buf[6:8], 0) + enc := int32(pseudoEncDesktopName) + binary.BigEndian.PutUint32(buf[8:12], uint32(enc)) + binary.BigEndian.PutUint32(buf[12:16], uint32(len(nameBytes))) + copy(buf[16:], nameBytes) + return buf +} + +// encodeLastRectBody emits a LastRect sentinel. When the server sets +// numRects=0xFFFF in the FramebufferUpdate header, the client reads rects +// until it sees one with this encoding. Lets us stream rects from a +// goroutine without committing to a count up front. +func encodeLastRectBody() []byte { + buf := make([]byte, 12) + // x, y, w, h all zero; encoding = -224. + enc := int32(pseudoEncLastRect) + binary.BigEndian.PutUint32(buf[8:12], uint32(enc)) + return buf +} + // encodeRawRect encodes a framebuffer region as a raw RFB rectangle. // The returned buffer includes the FramebufferUpdate header (1 rectangle). func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte { diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 1185360f301..f80fae600ca 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -34,13 +34,14 @@ const ( ) type session struct { - conn net.Conn - capturer ScreenCapturer - injector InputInjector - serverW int - serverH int - password string - log *log.Entry + conn net.Conn + capturer ScreenCapturer + injector InputInjector + serverW int + serverH int + desktopName string + password string + log *log.Entry writeMu sync.Mutex // encMu guards the negotiated pixel format and encoding state below. @@ -56,6 +57,12 @@ type session struct { zlib *zlibState tight *tightState copyRectDet *copyRectDetector + // Pseudo-encodings the client advertised support for. Updated under + // encMu by handleSetEncodings and read by the encoder goroutine. + clientSupportsDesktopSize bool + clientSupportsExtendedDesktopSize bool + clientSupportsDesktopName bool + clientSupportsLastRect bool // prevFrame, curFrame and idleFrames live on the encoder goroutine and // must not be touched elsewhere. curFrame holds a session-owned copy of // the capturer's latest frame so the encoder works on a stable buffer @@ -233,7 +240,11 @@ func (s *session) doVNCAuth() error { } func (s *session) sendServerInit() error { - name := []byte("NetBird VNC") + desktop := s.desktopName + if desktop == "" { + desktop = "NetBird VNC" + } + name := []byte(desktop) buf := make([]byte, 0, 4+16+4+len(name)) // Framebuffer width and height. @@ -333,6 +344,18 @@ func (s *session) handleSetEncodings() error { s.copyRectDet = newCopyRectDetector(tileSize) } encs = append(encs, "copyrect") + case pseudoEncDesktopSize: + s.clientSupportsDesktopSize = true + encs = append(encs, "desktop-size") + case pseudoEncExtendedDesktopSize: + s.clientSupportsExtendedDesktopSize = true + encs = append(encs, "ext-desktop-size") + case pseudoEncDesktopName: + s.clientSupportsDesktopName = true + encs = append(encs, "desktop-name") + case pseudoEncLastRect: + s.clientSupportsLastRect = true + encs = append(encs, "last-rect") case encZlib: s.useZlib = true if s.zlib == nil { @@ -401,6 +424,15 @@ func (s *session) encoderLoop(done chan<- struct{}) { } func (s *session) processFBRequest(req fbRequest) error { + // Watch for resolution changes between cycles. When the capturer + // reports a new size, tell the client via DesktopSize so it can + // reallocate its backing buffer; the next full update will then fill + // the new dimensions. Clients that didn't advertise support are stuck + // with the original handshake size and just see clipping on resize. + if err := s.handleResize(); err != nil { + return err + } + img, err := s.captureFrame() if errors.Is(err, errFrameUnchanged) { // macOS hashes the raw capture bytes and short-circuits when the @@ -503,6 +535,90 @@ func (s *session) captureRecovered() { } } +// handleResize detects framebuffer-size changes between encode cycles and +// notifies the client via the DesktopSize pseudo-encoding. Returns an +// error only on write failure; capturers that don't expose Width/Height +// yet (zero values during early startup) are silently ignored. +func (s *session) handleResize() error { + w, h := s.capturer.Width(), s.capturer.Height() + if w <= 0 || h <= 0 { + return nil + } + if w == s.serverW && h == s.serverH { + return nil + } + s.log.Debugf("framebuffer resized: %dx%d -> %dx%d", s.serverW, s.serverH, w, h) + s.serverW = w + s.serverH = h + // Drop the prev frame so the next encode produces a full update at + // the new dimensions rather than diffing against a stale-sized buffer. + s.prevFrame = nil + s.curFrame = nil + if s.copyRectDet != nil { + // Tile geometry changed; let updateDirty rebuild from scratch on + // the next pass instead of reusing stale hashes keyed on old + // (cols, rows). + s.copyRectDet.prevTiles = nil + s.copyRectDet.tileHash = nil + } + if err := s.sendDesktopSize(w, h); err != nil { + return fmt.Errorf("send desktop size: %w", err) + } + return nil +} + +// sendDesktopSize emits a single-rect FramebufferUpdate carrying the +// DesktopSize pseudo-encoding. No-op if the client did not negotiate it, +// in which case the client just sees the new dimensions on the next full +// update and will likely clip or scale. +func (s *session) sendDesktopSize(w, h int) error { + s.encMu.RLock() + supported := s.clientSupportsDesktopSize || s.clientSupportsExtendedDesktopSize + s.encMu.RUnlock() + if !supported { + return nil + } + header := make([]byte, 4) + header[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(header[2:4], 1) + + body := encodeDesktopSizeBody(w, h) + s.writeMu.Lock() + defer s.writeMu.Unlock() + if _, err := s.conn.Write(header); err != nil { + return err + } + _, err := s.conn.Write(body) + return err +} + +// SendDesktopName pushes a DesktopName pseudo-encoded update to the +// client if it advertised support. Used by the server to keep the +// dashboard title in sync with the active session (e.g. username +// changes after login on a virtual session). +func (s *session) SendDesktopName(name string) error { + s.encMu.RLock() + supported := s.clientSupportsDesktopName + s.encMu.RUnlock() + if !supported { + s.desktopName = name + return nil + } + s.desktopName = name + header := make([]byte, 4) + header[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(header[2:4], 1) + + body := encodeDesktopNameBody(name) + s.writeMu.Lock() + defer s.writeMu.Unlock() + if _, err := s.conn.Write(header); err != nil { + return err + } + _, err := s.conn.Write(body) + return err +} + // refreshCopyRectIndex does a full hash sweep of the just-swapped prevFrame. // Used after full-frame sends, where we don't have a per-tile dirty list to // drive an incremental update. From 6d937af7a0862eb0b359bf6577dcdff7c788bfc6 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:48:21 +0200 Subject: [PATCH 016/151] Drop dead Hextile and standalone Zlib encoding paths --- client/vnc/server/hextile_test.go | 188 -------------------- client/vnc/server/rfb.go | 264 ---------------------------- client/vnc/server/rfb_bench_test.go | 70 +++----- client/vnc/server/session.go | 68 +++---- client/vnc/server/tight_test.go | 26 +++ 5 files changed, 71 insertions(+), 545 deletions(-) delete mode 100644 client/vnc/server/hextile_test.go diff --git a/client/vnc/server/hextile_test.go b/client/vnc/server/hextile_test.go deleted file mode 100644 index 69a1904d456..00000000000 --- a/client/vnc/server/hextile_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package server - -import ( - "image" - "testing" -) - -// roundTrip decodes an encoded Hextile rect back into pixels and checks it -// matches the source. Implements just enough of the noVNC Hextile decoder -// to validate our encoder. -func decodeHextile(t *testing.T, buf []byte, pf clientPixelFormat) *image.RGBA { - t.Helper() - if len(buf) < 12 { - t.Fatalf("buf too short: %d", len(buf)) - } - x := int(uint16(buf[0])<<8 | uint16(buf[1])) - y := int(uint16(buf[2])<<8 | uint16(buf[3])) - w := int(uint16(buf[4])<<8 | uint16(buf[5])) - h := int(uint16(buf[6])<<8 | uint16(buf[7])) - enc := uint32(buf[8])<<24 | uint32(buf[9])<<16 | uint32(buf[10])<<8 | uint32(buf[11]) - if enc != encHextile { - t.Fatalf("not hextile: %d", enc) - } - body := buf[12:] - bytesPerPixel := max(int(pf.bpp)/8, 1) - out := image.NewRGBA(image.Rect(x, y, x+w, y+h)) - - var bg, fg [3]byte - pos := 0 - readPixel := func() [3]byte { - var v uint32 - if pf.bigEndian != 0 { - for i := 0; i < bytesPerPixel; i++ { - v |= uint32(body[pos+i]) << (8 * (bytesPerPixel - 1 - i)) - } - } else { - for i := 0; i < bytesPerPixel; i++ { - v |= uint32(body[pos+i]) << (8 * i) - } - } - pos += bytesPerPixel - r := byte((v >> pf.rShift) & uint32(pf.rMax)) - g := byte((v >> pf.gShift) & uint32(pf.gMax)) - b := byte((v >> pf.bShift) & uint32(pf.bMax)) - return [3]byte{r, g, b} - } - for sy := 0; sy < h; sy += hextileSubSize { - sh := min(hextileSubSize, h-sy) - for sx := 0; sx < w; sx += hextileSubSize { - sw := min(hextileSubSize, w-sx) - flags := body[pos] - pos++ - if flags&hextileRaw != 0 { - for ry := 0; ry < sh; ry++ { - for rx := 0; rx < sw; rx++ { - px := readPixel() - i := (sy+ry)*out.Stride + (sx+rx)*4 - out.Pix[i+0] = px[0] - out.Pix[i+1] = px[1] - out.Pix[i+2] = px[2] - out.Pix[i+3] = 0xff - } - } - continue - } - if flags&hextileBackgroundSpecified != 0 { - bg = readPixel() - } - if flags&hextileForegroundSpecified != 0 { - fg = readPixel() - } - // Fill sub-tile with bg. - for ry := 0; ry < sh; ry++ { - for rx := 0; rx < sw; rx++ { - i := (sy+ry)*out.Stride + (sx+rx)*4 - out.Pix[i+0] = bg[0] - out.Pix[i+1] = bg[1] - out.Pix[i+2] = bg[2] - out.Pix[i+3] = 0xff - } - } - if flags&hextileAnySubrects == 0 { - continue - } - n := int(body[pos]) - pos++ - for k := 0; k < n; k++ { - color := fg - if flags&hextileSubrectsColoured != 0 { - color = readPixel() - } - xy := body[pos] - wh := body[pos+1] - pos += 2 - rxr := int(xy >> 4) - ryr := int(xy & 0x0f) - rwr := int(wh>>4) + 1 - rhr := int(wh&0x0f) + 1 - for ry := 0; ry < rhr; ry++ { - for rx := 0; rx < rwr; rx++ { - i := (sy+ryr+ry)*out.Stride + (sx+rxr+rx)*4 - out.Pix[i+0] = color[0] - out.Pix[i+1] = color[1] - out.Pix[i+2] = color[2] - out.Pix[i+3] = 0xff - } - } - } - } - } - return out -} - -func makeUniformImage(w, h int, r, g, b byte) *image.RGBA { - img := image.NewRGBA(image.Rect(0, 0, w, h)) - for i := 0; i < len(img.Pix); i += 4 { - img.Pix[i+0] = r - img.Pix[i+1] = g - img.Pix[i+2] = b - img.Pix[i+3] = 0xff - } - return img -} - -func makeTwoColorImage(w, h int) *image.RGBA { - img := makeUniformImage(w, h, 0x10, 0x20, 0x30) - // Draw a vertical bar of fg in the middle. - fg := [3]byte{0xa0, 0xb0, 0xc0} - for y := 0; y < h; y++ { - for x := w / 4; x < w/2; x++ { - i := y*img.Stride + x*4 - img.Pix[i+0] = fg[0] - img.Pix[i+1] = fg[1] - img.Pix[i+2] = fg[2] - } - } - return img -} - -func compareImages(t *testing.T, want, got *image.RGBA) { - t.Helper() - if want.Rect != got.Rect { - t.Fatalf("rect mismatch: %v vs %v", want.Rect, got.Rect) - } - w, h := want.Rect.Dx(), want.Rect.Dy() - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - i := y*want.Stride + x*4 - j := y*got.Stride + x*4 - if want.Pix[i] != got.Pix[j] || want.Pix[i+1] != got.Pix[j+1] || want.Pix[i+2] != got.Pix[j+2] { - t.Fatalf("pixel mismatch at (%d,%d): want %v got %v", - x, y, want.Pix[i:i+3], got.Pix[j:j+3]) - } - } - } -} - -func TestEncodeHextileRect_Uniform(t *testing.T) { - pf := defaultClientPixelFormat() - img := makeUniformImage(64, 64, 0x33, 0x66, 0x99) - buf := encodeHextileRect(img, pf, 0, 0, 64, 64) - got := decodeHextile(t, buf, pf) - compareImages(t, img, got) -} - -func TestEncodeHextileRect_TwoColor(t *testing.T) { - pf := defaultClientPixelFormat() - img := makeTwoColorImage(64, 64) - buf := encodeHextileRect(img, pf, 0, 0, 64, 64) - got := decodeHextile(t, buf, pf) - compareImages(t, img, got) -} - -func TestEncodeHextileRect_Multicolor(t *testing.T) { - pf := defaultClientPixelFormat() - img := makeBenchImage(64, 64, 42) - buf := encodeHextileRect(img, pf, 0, 0, 64, 64) - got := decodeHextile(t, buf, pf) - compareImages(t, img, got) -} - -func TestEncodeHextileRect_NonAligned(t *testing.T) { - pf := defaultClientPixelFormat() - img := makeTwoColorImage(50, 33) // not a multiple of 16 - buf := encodeHextileRect(img, pf, 0, 0, 50, 33) - got := decodeHextile(t, buf, pf) - compareImages(t, img, got) -} diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index f9a3af485d8..6dcb57a96cf 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -330,57 +330,6 @@ func reverseBits(b byte) byte { return r } -// encodeZlibRect encodes a framebuffer region using Zlib compression. -// The zlib stream is continuous for the entire VNC session: noVNC creates -// one inflate context at startup and reuses it for all zlib-encoded rects. -// We must NOT reset the zlib writer between calls. -func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zlibState) []byte { - bytesPerPixel := max(int(pf.bpp)/8, 1) - zw, zbuf := z.w, z.buf - - // Clear the output buffer but keep the deflate dictionary intact. - zbuf.Reset() - - // Encode the full rect pixel stream into the session-lived scratch buffer - // and feed zlib one row at a time. Row-granular writes amortise the per- - // Write overhead that used to dominate this function when it wrote one - // byte slice per pixel. - rowBytes := w * bytesPerPixel - total := rowBytes * h - if cap(z.scratch) < total { - z.scratch = make([]byte, total) - } - scratch := z.scratch[:total] - writePixels(scratch, img, pf, rect{x, y, w, h}, bytesPerPixel) - for row := 0; row < h; row++ { - if _, err := zw.Write(scratch[row*rowBytes : (row+1)*rowBytes]); err != nil { - log.Debugf("zlib write row %d: %v", row, err) - return nil - } - } - if err := zw.Flush(); err != nil { - log.Debugf("zlib flush: %v", err) - return nil - } - - compressed := zbuf.Bytes() - - // Build the FramebufferUpdate message. - buf := make([]byte, 4+12+4+len(compressed)) - buf[0] = serverFramebufferUpdate - buf[1] = 0 - binary.BigEndian.PutUint16(buf[2:4], 1) // 1 rectangle - - binary.BigEndian.PutUint16(buf[4:6], uint16(x)) - binary.BigEndian.PutUint16(buf[6:8], uint16(y)) - binary.BigEndian.PutUint16(buf[8:10], uint16(w)) - binary.BigEndian.PutUint16(buf[10:12], uint16(h)) - binary.BigEndian.PutUint32(buf[12:16], uint32(encZlib)) - binary.BigEndian.PutUint32(buf[16:20], uint32(len(compressed))) - copy(buf[20:], compressed) - - return buf -} // diffTiles compares two RGBA images and returns a tile-ordered list of // dirty tiles, one entry per tile. Tile order is top-to-bottom, left-to- @@ -565,219 +514,6 @@ func encodePixel(dst []byte, pf clientPixelFormat, r, g, b byte) int { return bytesPerPixel } -// encodeHextileSolidRect emits a Hextile-encoded rectangle whose every pixel -// is the same color. All sub-tiles after the first inherit the background -// via a zero subencoding byte, collapsing a uniform 64×64 tile from ~16 KB -// raw (or ~1-2 KB zlib) down to ~20 bytes on the wire. -// -// The returned buffer starts with the 12-byte rect header + the hextile -// body. Callers assembling a multi-rect FramebufferUpdate append this after -// their own message header. -func encodeHextileSolidRect(r, g, b byte, pf clientPixelFormat, rc rect) []byte { - bytesPerPixel := max(int(pf.bpp)/8, 1) - - // Count sub-tiles. Right/bottom sub-tiles may be smaller than 16. - cols := (rc.w + hextileSubSize - 1) / hextileSubSize - rows := (rc.h + hextileSubSize - 1) / hextileSubSize - subs := cols * rows - - // Body: first sub-tile carries (subenc 0x02 + bg pixel); the rest are - // subenc 0x00 (inherit the previously-emitted background). - bodySize := 1 + bytesPerPixel + (subs - 1) - buf := make([]byte, 12+bodySize) - - binary.BigEndian.PutUint16(buf[0:2], uint16(rc.x)) - binary.BigEndian.PutUint16(buf[2:4], uint16(rc.y)) - binary.BigEndian.PutUint16(buf[4:6], uint16(rc.w)) - binary.BigEndian.PutUint16(buf[6:8], uint16(rc.h)) - binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile)) - - buf[12] = hextileBackgroundSpecified - encodePixel(buf[13:13+bytesPerPixel], pf, r, g, b) - // Remaining sub-tiles are already zero-valued from make(): "same as - // previous background", no pixel bytes. - _ = subs - return buf -} - -// encodeHextileRect emits a full Hextile-encoded rectangle. Each 16×16 -// sub-tile is classified as 1-color (background only), 2-color (background -// + foreground subrects), or raw. The 1-color and 2-color paths are -// significantly cheaper than zlib on UI content (text, icons, flat -// backgrounds) and avoid the persistent zlib stream's inter-rect -// serialization point, so they parallelize trivially. -// -// The returned buffer starts with the 12-byte rect header + hextile body. -func encodeHextileRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte { - bytesPerPixel := max(int(pf.bpp)/8, 1) - - // Pre-size: worst case is every sub-tile raw → 1 header byte + raw - // pixels per sub-tile. - maxBody := 0 - for sy := 0; sy < h; sy += hextileSubSize { - sh := min(hextileSubSize, h-sy) - for sx := 0; sx < w; sx += hextileSubSize { - sw := min(hextileSubSize, w-sx) - maxBody += 1 + sw*sh*bytesPerPixel - } - } - buf := make([]byte, 12, 12+maxBody) - - binary.BigEndian.PutUint16(buf[0:2], uint16(x)) - binary.BigEndian.PutUint16(buf[2:4], uint16(y)) - binary.BigEndian.PutUint16(buf[4:6], uint16(w)) - binary.BigEndian.PutUint16(buf[6:8], uint16(h)) - binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile)) - - var state hextileBgState - - for sy := 0; sy < h; sy += hextileSubSize { - sh := min(hextileSubSize, h-sy) - for sx := 0; sx < w; sx += hextileSubSize { - sw := min(hextileSubSize, w-sx) - buf = appendHextileSubtile(buf, img, pf, rect{x + sx, y + sy, sw, sh}, &state, bytesPerPixel) - } - } - return buf -} - -// hextileBgState carries the running background across sub-tile encodes so -// we can omit the BackgroundSpecified flag when it hasn't changed. -type hextileBgState struct { - prev uint32 - valid bool -} - -// appendHextileSubtile encodes a single 16×16 (or smaller edge) sub-tile -// onto buf. -func appendHextileSubtile(buf []byte, img *image.RGBA, pf clientPixelFormat, rc rect, state *hextileBgState, bytesPerPixel int) []byte { - x, y, w, h := rc.x, rc.y, rc.w, rc.h - c0, c1, only2, c0Count, c1Count := classifySubtile(img, x, y, w, h) - - if !only2 { - // >2 distinct colours: raw fallback. - buf = append(buf, hextileRaw) - buf = appendRawPixels(buf, img, pf, rc, bytesPerPixel) - state.valid = false - return buf - } - - if c1Count == 0 { - // Single colour. Background only. - if state.valid && state.prev == c0 { - return append(buf, 0) - } - buf = append(buf, hextileBackgroundSpecified) - buf = appendPackedPixelFromRGBA(buf, pf, c0, bytesPerPixel) - state.prev = c0 - state.valid = true - return buf - } - - // Two colours. Background = majority; foreground = minority, - // emitted as 1-row subrects of fg runs. - bg, fg := c0, c1 - if c1Count > c0Count { - bg, fg = c1, c0 - } - subrects := collectFgSubrects(img, x, y, w, h, bg) - // Cap at 255 (the count is a uint8). On overflow fall through to - // raw: that's the simplest correct fallback. - if len(subrects) <= 255 { - flags := byte(hextileForegroundSpecified | hextileAnySubrects) - emitBg := !state.valid || state.prev != bg - if emitBg { - flags |= hextileBackgroundSpecified - } - buf = append(buf, flags) - if emitBg { - buf = appendPackedPixelFromRGBA(buf, pf, bg, bytesPerPixel) - state.prev = bg - state.valid = true - } - buf = appendPackedPixelFromRGBA(buf, pf, fg, bytesPerPixel) - buf = append(buf, byte(len(subrects))) - for _, sr := range subrects { - buf = append(buf, byte((sr[0]<<4)|sr[1]), byte(((sr[2]-1)<<4)|(sr[3]-1))) - } - return buf - } - - // Raw fallback. - buf = append(buf, hextileRaw) - buf = appendRawPixels(buf, img, pf, rc, bytesPerPixel) - // Raw sub-tiles invalidate the persistent background. - state.valid = false - return buf -} - -// classifySubtile scans the sub-tile and reports up to two distinct pixel -// values plus their counts. only2 is false the moment a third distinct -// colour is seen, in which case the caller falls back to raw. -func classifySubtile(img *image.RGBA, x, y, w, h int) (c0, c1 uint32, only2 bool, c0Count, c1Count int) { - stride := img.Stride - base := y*stride + x*4 - c0 = *(*uint32)(unsafe.Pointer(&img.Pix[base])) - only2 = true - for row := 0; row < h; row++ { - p := base + row*stride - for col := 0; col < w; col++ { - px := *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) - switch { - case px == c0: - c0Count++ - case c1Count == 0: - c1 = px - c1Count = 1 - case px == c1: - c1Count++ - default: - return c0, c1, false, 0, 0 - } - } - } - return c0, c1, only2, c0Count, c1Count -} - -// collectFgSubrects walks the sub-tile row by row, emitting one subrect per -// horizontal run of pixels not equal to bg. Each subrect is [subX, subY, -// width, height] with width/height in 1..16. -func collectFgSubrects(img *image.RGBA, x, y, w, h int, bg uint32) [][4]int { - stride := img.Stride - var out [][4]int - for row := 0; row < h; row++ { - p := y*stride + x*4 + row*stride - col := 0 - for col < w { - if *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) == bg { - col++ - continue - } - start := col - for col < w && *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) != bg { - col++ - } - out = append(out, [4]int{start, row, col - start, 1}) - } - } - return out -} - -func appendPackedPixelFromRGBA(buf []byte, pf clientPixelFormat, px uint32, bytesPerPixel int) []byte { - r := byte(px) - g := byte(px >> 8) - b := byte(px >> 16) - var tmp [4]byte - encodePixel(tmp[:], pf, r, g, b) - return append(buf, tmp[:bytesPerPixel]...) -} - -func appendRawPixels(buf []byte, img *image.RGBA, pf clientPixelFormat, rc rect, bytesPerPixel int) []byte { - start := len(buf) - buf = append(buf, make([]byte, rc.w*rc.h*bytesPerPixel)...) - writePixels(buf[start:], img, pf, rc, bytesPerPixel) - return buf -} // tightState holds the per-session JPEG scratch buffer and reused encoders // so per-rect encoding stays alloc-free in the steady state. diff --git a/client/vnc/server/rfb_bench_test.go b/client/vnc/server/rfb_bench_test.go index c26672da92c..4a011553653 100644 --- a/client/vnc/server/rfb_bench_test.go +++ b/client/vnc/server/rfb_bench_test.go @@ -58,16 +58,16 @@ func BenchmarkEncodeRawRect(b *testing.B) { } } -func BenchmarkEncodeZlibRect(b *testing.B) { +func BenchmarkEncodeTightRect(b *testing.B) { pf := defaultClientPixelFormat() for _, r := range benchRects { img := makeBenchImage(r.w, r.h, 1) - z := newZlibState() + t := newTightState() b.Run(r.name, func(b *testing.B) { b.SetBytes(int64(r.w * r.h * 4)) b.ReportAllocs() for i := 0; i < b.N; i++ { - _ = encodeZlibRect(img, pf, 0, 0, r.w, r.h, z) + _ = encodeTightRect(img, pf, 0, 0, r.w, r.h, t) } }) } @@ -151,9 +151,9 @@ func BenchmarkSwizzleBGRAtoRGBANaive(b *testing.B) { } } -// BenchmarkEncodeUniformTile_Zlib measures the cost of sending a uniform -// 64×64 dirty tile via zlib (the old path before the Hextile fast path). -func BenchmarkEncodeUniformTile_Zlib(b *testing.B) { +// BenchmarkEncodeUniformTile_TightFill measures the fast path for a uniform +// 64×64 tile via Tight's Fill subencoding (16 wire bytes regardless of size). +func BenchmarkEncodeUniformTile_TightFill(b *testing.B) { pf := defaultClientPixelFormat() img := image.NewRGBA(image.Rect(0, 0, 64, 64)) for i := 0; i < len(img.Pix); i += 4 { @@ -162,24 +162,11 @@ func BenchmarkEncodeUniformTile_Zlib(b *testing.B) { img.Pix[i+2] = 0x99 img.Pix[i+3] = 0xff } - z := newZlibState() + t := newTightState() b.ReportAllocs() var bytesOut int for i := 0; i < b.N; i++ { - out := encodeZlibRect(img, pf, 0, 0, 64, 64, z) - bytesOut = len(out) - } - b.ReportMetric(float64(bytesOut), "wire_bytes") -} - -// BenchmarkEncodeUniformTile_Hextile measures the new fast path: uniform -// 64×64 tile emitted as Hextile SolidFill. -func BenchmarkEncodeUniformTile_Hextile(b *testing.B) { - pf := defaultClientPixelFormat() - b.ReportAllocs() - var bytesOut int - for i := 0; i < b.N; i++ { - out := encodeHextileSolidRect(0x33, 0x66, 0x99, pf, rect{0, 0, 64, 64}) + out := encodeTightRect(img, pf, 0, 0, 64, 64, t) bytesOut = len(out) } b.ReportMetric(float64(bytesOut), "wire_bytes") @@ -198,7 +185,7 @@ func BenchmarkTileIsUniform(b *testing.B) { // BenchmarkEncodeManyTilesVsFullFrame exercises the bandwidth + CPU // trade-off that motivates the full-frame promotion path: encoding a burst -// of N dirty 64×64 tiles as separate zlib rects vs emitting one big zlib +// of N dirty 64×64 tiles as separate Tight rects vs emitting one big Tight // rect for the whole frame. func BenchmarkEncodeManyTilesVsFullFrame(b *testing.B) { pf := defaultClientPixelFormat() @@ -222,15 +209,15 @@ func BenchmarkEncodeManyTilesVsFullFrame(b *testing.B) { } nTiles := len(tiles) - b.Run("per_tile_zlib", func(b *testing.B) { - z := newZlibState() + b.Run("per_tile_tight", func(b *testing.B) { + t := newTightState() b.SetBytes(int64(w * h * 4)) b.ReportAllocs() var totalOut int for i := 0; i < b.N; i++ { totalOut = 0 for _, r := range tiles { - out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z) + out := encodeTightRect(img, pf, r[0], r[1], r[2], r[3], t) totalOut += len(out) } } @@ -238,13 +225,13 @@ func BenchmarkEncodeManyTilesVsFullFrame(b *testing.B) { b.ReportMetric(float64(nTiles), "tiles") }) - b.Run("full_frame_zlib", func(b *testing.B) { - z := newZlibState() + b.Run("full_frame_tight", func(b *testing.B) { + t := newTightState() b.SetBytes(int64(w * h * 4)) b.ReportAllocs() var totalOut int for i := 0; i < b.N; i++ { - out := encodeZlibRect(img, pf, 0, 0, w, h, z) + out := encodeTightRect(img, pf, 0, 0, w, h, t) totalOut = len(out) } b.ReportMetric(float64(totalOut), "wire_bytes") @@ -297,13 +284,13 @@ func BenchmarkEncodeCoalescedVsPerTile(b *testing.B) { coalesced := coalesceRects(append([][4]int(nil), perTile...)) b.Run("per_tile", func(b *testing.B) { - z := newZlibState() + t := newTightState() b.ReportAllocs() var bytesOut int for i := 0; i < b.N; i++ { bytesOut = 0 for _, r := range perTile { - out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z) + out := encodeTightRect(img, pf, r[0], r[1], r[2], r[3], t) bytesOut += len(out) } } @@ -312,13 +299,13 @@ func BenchmarkEncodeCoalescedVsPerTile(b *testing.B) { }) b.Run("coalesced", func(b *testing.B) { - z := newZlibState() + t := newTightState() b.ReportAllocs() var bytesOut int for i := 0; i < b.N; i++ { bytesOut = 0 for _, r := range coalesced { - out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z) + out := encodeTightRect(img, pf, r[0], r[1], r[2], r[3], t) bytesOut += len(out) } } @@ -352,10 +339,10 @@ func BenchmarkCoalesceRects(b *testing.B) { } } -// BenchmarkEncodeTightVsZlib_Photo compares Tight (which routes random/ -// photographic content to JPEG) against the persistent Zlib stream. JPEG -// at quality 70 should be 5-15× smaller on this kind of content. -func BenchmarkEncodeTightVsZlib_Photo(b *testing.B) { +// BenchmarkEncodeTight_Photo measures Tight on random/photographic content. +// The internal sampledColorCount gate routes large many-colour rects to JPEG +// at quality 70. +func BenchmarkEncodeTight_Photo(b *testing.B) { pf := defaultClientPixelFormat() for _, r := range []struct { name string @@ -366,17 +353,6 @@ func BenchmarkEncodeTightVsZlib_Photo(b *testing.B) { {"1080p", 1920, 1080}, } { img := makeBenchImage(r.w, r.h, 1) - b.Run(r.name+"/zlib", func(b *testing.B) { - z := newZlibState() - b.SetBytes(int64(r.w * r.h * 4)) - b.ReportAllocs() - var bytesOut int - for i := 0; i < b.N; i++ { - out := encodeZlibRect(img, pf, 0, 0, r.w, r.h, z) - bytesOut = len(out) - } - b.ReportMetric(float64(bytesOut), "wire_bytes") - }) b.Run(r.name+"/tight", func(b *testing.B) { t := newTightState() b.SetBytes(int64(r.w * r.h * 4)) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index f80fae600ca..7433731c78c 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -50,11 +50,8 @@ type session struct { // reads them on every frame. encMu sync.RWMutex pf clientPixelFormat - useZlib bool - useHextile bool useTight bool useCopyRect bool - zlib *zlibState tight *tightState copyRectDet *copyRectDetector // Pseudo-encodings the client advertised support for. Updated under @@ -356,15 +353,6 @@ func (s *session) handleSetEncodings() error { case pseudoEncLastRect: s.clientSupportsLastRect = true encs = append(encs, "last-rect") - case encZlib: - s.useZlib = true - if s.zlib == nil { - s.zlib = newZlibState() - } - encs = append(encs, "zlib") - case encHextile: - s.useHextile = true - encs = append(encs, "hextile") case encTight: s.useTight = true if s.tight == nil { @@ -705,17 +693,26 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { s.encMu.RLock() pf := s.pf - useZlib := s.useZlib - zlib := s.zlib + useTight := s.useTight + tight := s.tight s.encMu.RUnlock() - var buf []byte - if useZlib && zlib != nil { - buf = encodeZlibRect(img, pf, 0, 0, w, h, zlib) - } else { - buf = encodeRawRect(img, pf, 0, 0, w, h) + if useTight && tight != nil && pfIsTightCompatible(pf) { + // Tight encodes arbitrary sizes natively (Fill for uniform, JPEG + // for photo-like, Basic+zlib otherwise). Wrap the rect bytes with + // the 4-byte FramebufferUpdate header. + rectBuf := encodeTightRect(img, pf, 0, 0, w, h, tight) + buf := make([]byte, 4+len(rectBuf)) + buf[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(buf[2:4], 1) + copy(buf[4:], rectBuf) + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err } + buf := encodeRawRect(img, pf, 0, 0, w, h) s.writeMu.Lock() _, err := s.conn.Write(buf) s.writeMu.Unlock() @@ -761,46 +758,25 @@ func (s *session) sendDirtyAndMoves(img *image.RGBA, moves []copyRectMove, rects return nil } -// encodeTile produces the on-wire rect bytes for a single dirty tile, -// picking the cheapest encoding available: -// - Hextile SolidFill when the tile is a single colour (~20 bytes for a -// 64×64 tile instead of ~1-2 KB zlib, ~16 KB raw). -// - Zlib when the client negotiated it. -// - Raw otherwise. +// encodeTile produces the on-wire rect bytes for a single dirty tile. Tight +// is the only non-Raw encoding we negotiate: uniform tiles collapse to its +// Fill subencoding (~16 bytes), photo-like rects route to JPEG, and the +// rest take the Basic+zlib path. Raw is the fallback when Tight is not +// negotiated or the negotiated pixel format is incompatible with Tight's +// mandatory 24-bit RGB TPIXEL encoding. // // Output omits the 4-byte FramebufferUpdate header; callers combine multiple // tiles into one message. func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { s.encMu.RLock() pf := s.pf - useHextile := s.useHextile useTight := s.useTight tight := s.tight - useZlib := s.useZlib - zlib := s.zlib s.encMu.RUnlock() - if useHextile { - if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform { - r := byte(pixel) - g := byte(pixel >> 8) - b := byte(pixel >> 16) - return encodeHextileSolidRect(r, g, b, pf, rect{x, y, w, h}) - } - // Full Hextile encoder disabled pending investigation of 16x16 - // red-tile artifacts on Windows. Solid-fill fast path is safe. - } - // Larger merged rects: prefer Tight (JPEG for photo-like, Basic+zlib - // otherwise) when the client supports it AND the negotiated format is - // compatible with Tight's mandatory 24-bit RGB TPIXEL encoding. Tight is - // dramatically better than RFB Zlib on photographic content and - // competitive on UI. if useTight && tight != nil && pfIsTightCompatible(pf) { return encodeTightRect(img, pf, x, y, w, h, tight) } - if useZlib && zlib != nil { - return encodeZlibRect(img, pf, x, y, w, h, zlib)[4:] - } return encodeRawRect(img, pf, x, y, w, h)[4:] } diff --git a/client/vnc/server/tight_test.go b/client/vnc/server/tight_test.go index 0e0aaab4db6..698f703e8c7 100644 --- a/client/vnc/server/tight_test.go +++ b/client/vnc/server/tight_test.go @@ -2,10 +2,36 @@ package server import ( "bytes" + "image" "image/jpeg" "testing" ) +func makeUniformImage(w, h int, r, g, b byte) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i+0] = r + img.Pix[i+1] = g + img.Pix[i+2] = b + img.Pix[i+3] = 0xff + } + return img +} + +func makeTwoColorImage(w, h int) *image.RGBA { + img := makeUniformImage(w, h, 0x10, 0x20, 0x30) + fg := [3]byte{0xa0, 0xb0, 0xc0} + for y := 0; y < h; y++ { + for x := w / 4; x < w/2; x++ { + i := y*img.Stride + x*4 + img.Pix[i+0] = fg[0] + img.Pix[i+1] = fg[1] + img.Pix[i+2] = fg[2] + } + } + return img +} + func decodeTightLength(buf []byte) (n, consumed int) { b0 := buf[0] n = int(b0 & 0x7f) From b4f696272a424ad235a134c555af5e33f9f1b8b0 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:51:03 +0200 Subject: [PATCH 017/151] Drop unused VNC DES auth path --- client/cmd/vnc_agent.go | 2 +- client/internal/engine_vnc.go | 2 +- client/vnc/server/rfb.go | 37 +-------------------- client/vnc/server/server.go | 7 ++-- client/vnc/server/server_test.go | 14 ++++---- client/vnc/server/session.go | 57 ++++---------------------------- 6 files changed, 19 insertions(+), 100 deletions(-) diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go index 5fe6c97a030..a6a583ac7ed 100644 --- a/client/cmd/vnc_agent.go +++ b/client/cmd/vnc_agent.go @@ -44,7 +44,7 @@ var vncAgentCmd = &cobra.Command{ capturer := vncserver.NewDesktopCapturer() injector := vncserver.NewWindowsInputInjector() - srv := vncserver.New(capturer, injector, "") + srv := vncserver.New(capturer, injector) srv.SetDisableAuth(true) srv.SetAgentToken(token) diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 38de3feba22..a7d9b1012e0 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -99,7 +99,7 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { netbirdIP := e.wgInterface.Address().IP - srv := vncserver.New(capturer, injector, "") + srv := vncserver.New(capturer, injector) if vncNeedsServiceMode() { log.Info("VNC: running in Session 0, enabling service mode (agent proxy)") srv.SetServiceMode(true) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 6dcb57a96cf..e4e87063b39 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -3,9 +3,7 @@ package server import ( "bytes" "compress/zlib" - "crypto/des" //nolint:gosec // RFB protocol-defined DES challenge/response; not used for confidentiality "encoding/binary" - "fmt" "image" "image/jpeg" "unsafe" @@ -21,8 +19,7 @@ type rect struct { const ( rfbProtocolVersion = "RFB 003.008\n" - secNone = 1 - secVNCAuth = 2 + secNone = 1 // Client message types. clientSetPixelFormat = 0 @@ -297,38 +294,6 @@ func emitPixelBytes(dst []byte, pixel uint32, bytesPerPixel int, bigEndian bool) } } -// vncAuthEncrypt encrypts a 16-byte challenge using the VNC DES scheme. -func vncAuthEncrypt(challenge []byte, password string) ([]byte, error) { - key := make([]byte, 8) - pw := []byte(password) - n := len(pw) - if n > 8 { - n = 8 - } - for i := 0; i < n; i++ { - key[i] = reverseBits(pw[i]) - } - block, err := des.NewCipher(key) //nolint:gosec // RFB protocol-defined DES challenge/response; not a confidentiality cipher - if err != nil { - return nil, fmt.Errorf("des.NewCipher: %w", err) - } - if len(challenge) < 16 { //nolint:gosec // explicit length check disarms G602 - return nil, fmt.Errorf("vnc auth challenge too short: %d", len(challenge)) - } - out := make([]byte, 16) - block.Encrypt(out[:8], challenge[:8]) - block.Encrypt(out[8:], challenge[8:]) - return out, nil -} - -func reverseBits(b byte) byte { - var r byte - for range 8 { - r = (r << 1) | (b & 1) - b >>= 1 - } - return r -} // diffTiles compares two RGBA images and returns a tile-ordered list of diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 27149a2252d..237a45aa603 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -129,7 +129,6 @@ type connectionHeader struct { type Server struct { capturer ScreenCapturer injector InputInjector - password string serviceMode bool disableAuth bool localAddr netip.Addr // NetBird WireGuard IP this server is bound to @@ -179,11 +178,12 @@ type virtualSessionManager interface { } // New creates a VNC server with the given screen capturer and input injector. -func New(capturer ScreenCapturer, injector InputInjector, password string) *Server { +// Authentication is handled by the dashboard JWT exchange after the RFB +// handshake; the protocol-level VNC password scheme is not supported. +func New(capturer ScreenCapturer, injector InputInjector) *Server { return &Server{ capturer: capturer, injector: injector, - password: password, authorizer: sshauth.NewAuthorizer(), log: log.WithField("component", "vnc-server"), sessions: make(map[uint64]ActiveSessionInfo), @@ -478,7 +478,6 @@ func (s *Server) handleConnection(conn net.Conn) { injector: injector, serverW: capturer.Width(), serverH: capturer.Height(), - password: s.password, log: connLog, } sess.serve() diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index 6467aacc85b..9776667af8d 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -27,7 +27,7 @@ func (t *testCapturer) Capture() (*image.RGBA, error) { func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net.Addr, *Server) { t.Helper() - srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv := New(&testCapturer{}, &StubInputInjector{}) srv.SetDisableAuth(disableAuth) if jwtConfig != nil { srv.SetJWTConfig(jwtConfig) @@ -175,7 +175,7 @@ func TestAuthEnabled_InvalidJWT_RejectedBeforeRFB(t *testing.T) { // server must close immediately and the client must see EOF before any RFB // version greeting is written. func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv := New(&testCapturer{}, &StubInputInjector{}) srv.SetDisableAuth(true) addr := netip.MustParseAddrPort("127.0.0.1:0") // Tight overlay that excludes 127.0.0.0/8 and a non-loopback local IP, so @@ -287,7 +287,7 @@ func TestIsAllowedSource(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv := New(&testCapturer{}, &StubInputInjector{}) srv.localAddr = tc.localAddr srv.network = tc.network assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote)) @@ -296,7 +296,7 @@ func TestIsAllowedSource(t *testing.T) { } func TestStart_InvalidNetworkRejected(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv := New(&testCapturer{}, &StubInputInjector{}) addr := netip.MustParseAddrPort("127.0.0.1:0") err := srv.Start(t.Context(), addr, netip.Prefix{}) require.Error(t, err, "Start must refuse an invalid overlay prefix") @@ -304,7 +304,7 @@ func TestStart_InvalidNetworkRejected(t *testing.T) { } func TestAgentToken_MismatchClosesConnection(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv := New(&testCapturer{}, &StubInputInjector{}) srv.SetDisableAuth(true) srv.SetAgentToken("deadbeefcafebabe") @@ -332,7 +332,7 @@ func TestAgentToken_MismatchClosesConnection(t *testing.T) { } func TestAgentToken_MatchAllowsHandshake(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv := New(&testCapturer{}, &StubInputInjector{}) srv.SetDisableAuth(true) const tokenHex = "deadbeefcafebabe" srv.SetAgentToken(tokenHex) @@ -369,7 +369,7 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) { func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) { // Default platformSessionManager() on non-Linux returns nil, so ModeSession // must be rejected with the UNSUPPORTED reason rather than crashing. - srv := New(&testCapturer{}, &StubInputInjector{}, "") + srv := New(&testCapturer{}, &StubInputInjector{}) srv.SetDisableAuth(true) addr := netip.MustParseAddrPort("127.0.0.1:0") diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 7433731c78c..d2dd8879771 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -1,8 +1,6 @@ package server import ( - "bytes" - "crypto/rand" "encoding/binary" "errors" "fmt" @@ -40,7 +38,6 @@ type session struct { serverW int serverH int desktopName string - password string log *log.Entry writeMu sync.Mutex @@ -179,61 +176,19 @@ func (s *session) handshake() error { return s.sendServerInit() } +// sendSecurityTypes advertises only secNone. Authentication and access +// control are layered on top by the dashboard JWT exchange after the RFB +// handshake completes, not by the protocol-level password scheme. func (s *session) sendSecurityTypes() error { - if s.password == "" { - _, err := s.conn.Write([]byte{1, secNone}) - return err - } - _, err := s.conn.Write([]byte{1, secVNCAuth}) + _, err := s.conn.Write([]byte{1, secNone}) return err } func (s *session) handleSecurity(secType byte) error { - switch secType { - case secVNCAuth: - return s.doVNCAuth() - case secNone: - return binary.Write(s.conn, binary.BigEndian, uint32(0)) - default: + if secType != secNone { return fmt.Errorf("unsupported security type: %d", secType) } -} - -func (s *session) doVNCAuth() error { - challenge := make([]byte, 16) - if _, err := rand.Read(challenge); err != nil { - return fmt.Errorf("generate challenge: %w", err) - } - if _, err := s.conn.Write(challenge); err != nil { - return fmt.Errorf("send challenge: %w", err) - } - - response := make([]byte, 16) - if _, err := io.ReadFull(s.conn, response); err != nil { - return fmt.Errorf("read auth response: %w", err) - } - - var result uint32 - if s.password != "" { - expected, err := vncAuthEncrypt(challenge, s.password) - if err != nil { - return fmt.Errorf("vnc auth encrypt: %w", err) - } - if !bytes.Equal(expected, response) { - result = 1 - } - } - - if err := binary.Write(s.conn, binary.BigEndian, result); err != nil { - return fmt.Errorf("send auth result: %w", err) - } - if result != 0 { - msg := "authentication failed" - _ = binary.Write(s.conn, binary.BigEndian, uint32(len(msg))) - _, _ = s.conn.Write([]byte(msg)) - return fmt.Errorf("authentication failed from %s", s.addr()) - } - return nil + return binary.Write(s.conn, binary.BigEndian, uint32(0)) } func (s *session) sendServerInit() error { From 2bed8b641b1c4cdc18010d238a6716de65516263 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:54:58 +0200 Subject: [PATCH 018/151] Lock pixel format to 32bpp little-endian truecolour and reject other formats --- client/vnc/server/rfb.go | 173 ++++++++-------------------- client/vnc/server/rfb_bench_test.go | 21 +--- client/vnc/server/session.go | 14 +-- 3 files changed, 58 insertions(+), 150 deletions(-) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index e4e87063b39..e5eafada2cd 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/zlib" "encoding/binary" + "fmt" "image" "image/jpeg" "unsafe" @@ -80,24 +81,17 @@ const ( // Distinct-colour cap below which we still prefer Basic+zlib (text, // UI). Sampled, not exhaustive: cheap to compute, good enough. tightJPEGMinColors = 64 - - // Hextile subencoding flags (a bitmask in the first byte of each sub-tile). - hextileRaw = 0x01 - hextileBackgroundSpecified = 0x02 - hextileForegroundSpecified = 0x04 - hextileAnySubrects = 0x08 - hextileSubrectsColoured = 0x10 - - // Hextile sub-tile size per RFB spec. - hextileSubSize = 16 ) -// serverPixelFormat is the default pixel format advertised by the server: -// 32bpp RGBA, big-endian, true-colour, 8 bits per channel. +// serverPixelFormat is the pixel format the server advertises and requires: +// 32bpp RGBA, little-endian, true-colour, 8 bits per channel at standard +// shifts (R=16, G=8, B=0). handleSetPixelFormat rejects any client that +// negotiates a different format. Browser-side decoders are little-endian +// natively, so advertising little-endian skips a byte-swap on every pixel. var serverPixelFormat = [16]byte{ 32, // bits-per-pixel 24, // depth - 1, // big-endian-flag + 0, // big-endian-flag 1, // true-colour-flag 0, 255, // red-max 0, 255, // green-max @@ -108,42 +102,48 @@ var serverPixelFormat = [16]byte{ 0, 0, 0, // padding } -// clientPixelFormat holds the negotiated pixel format from the client. +// clientPixelFormat holds the negotiated pixel format. Only RGB channel +// shifts are tracked: every other field is constrained by the server to +// the values in serverPixelFormat (32bpp / little-endian / truecolour / +// 8-bit channels) and rejected at SetPixelFormat time if the client tries +// to negotiate otherwise. type clientPixelFormat struct { - bpp uint8 - bigEndian uint8 - rMax uint16 - gMax uint16 - bMax uint16 - rShift uint8 - gShift uint8 - bShift uint8 + rShift uint8 + gShift uint8 + bShift uint8 } func defaultClientPixelFormat() clientPixelFormat { return clientPixelFormat{ - bpp: serverPixelFormat[0], - bigEndian: serverPixelFormat[2], - rMax: binary.BigEndian.Uint16(serverPixelFormat[4:6]), - gMax: binary.BigEndian.Uint16(serverPixelFormat[6:8]), - bMax: binary.BigEndian.Uint16(serverPixelFormat[8:10]), - rShift: serverPixelFormat[10], - gShift: serverPixelFormat[11], - bShift: serverPixelFormat[12], + rShift: serverPixelFormat[10], + gShift: serverPixelFormat[11], + bShift: serverPixelFormat[12], + } +} + +// parsePixelFormat returns the negotiated client pixel format, or an error +// if the client tried to negotiate an unsupported format. The server only +// supports 32bpp truecolour little-endian with 8-bit channels; arbitrary +// shifts within that constraint are allowed because they are cheap to honour. +func parsePixelFormat(pf []byte) (clientPixelFormat, error) { + bpp := pf[0] + bigEndian := pf[2] + trueColour := pf[3] + rMax := binary.BigEndian.Uint16(pf[4:6]) + gMax := binary.BigEndian.Uint16(pf[6:8]) + bMax := binary.BigEndian.Uint16(pf[8:10]) + if bpp != 32 || bigEndian != 0 || trueColour != 1 || + rMax != 255 || gMax != 255 || bMax != 255 { + return clientPixelFormat{}, fmt.Errorf( + "unsupported pixel format (bpp=%d be=%d tc=%d rgb-max=%d/%d/%d): "+ + "server only supports 32bpp truecolour little-endian 8-bit channels", + bpp, bigEndian, trueColour, rMax, gMax, bMax) } -} - -func parsePixelFormat(pf []byte) clientPixelFormat { return clientPixelFormat{ - bpp: pf[0], - bigEndian: pf[2], - rMax: binary.BigEndian.Uint16(pf[4:6]), - gMax: binary.BigEndian.Uint16(pf[6:8]), - bMax: binary.BigEndian.Uint16(pf[8:10]), - rShift: pf[10], - gShift: pf[11], - bShift: pf[12], - } + rShift: pf[10], + gShift: pf[11], + bShift: pf[12], + }, nil } // encodeCopyRectBody emits the per-rect payload for a CopyRect rectangle: @@ -211,10 +211,7 @@ func encodeLastRectBody() []byte { // encodeRawRect encodes a framebuffer region as a raw RFB rectangle. // The returned buffer includes the FramebufferUpdate header (1 rectangle). func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte { - bytesPerPixel := max(int(pf.bpp)/8, 1) - - pixelBytes := w * h * bytesPerPixel - buf := make([]byte, 4+12+pixelBytes) + buf := make([]byte, 4+12+w*h*4) // FramebufferUpdate header. buf[0] = serverFramebufferUpdate @@ -228,26 +225,17 @@ func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte binary.BigEndian.PutUint16(buf[10:12], uint16(h)) binary.BigEndian.PutUint32(buf[12:16], uint32(encRaw)) - writePixels(buf[16:], img, pf, rect{x, y, w, h}, bytesPerPixel) + writePixels(buf[16:], img, pf, rect{x, y, w, h}) return buf } -// writePixels writes a rectangle of img into dst in the client's requested -// pixel format. It fast-paths the common case (32bpp, full 8-bit channels) -// with a tight loop that skips the per-channel *max/255 arithmetic and emits -// a single uint32 per pixel; the general path handles arbitrary formats. -func writePixels(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect, bytesPerPixel int) { - if bytesPerPixel == 4 && pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 { - writePixelsFast32(dst, img, pf, r) - return - } - writePixelsGeneric(dst, img, pf, r, bytesPerPixel) -} - -func writePixelsFast32(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect) { +// writePixels writes a rectangle of img into dst as 32bpp little-endian +// pixels at the negotiated RGB shifts. The pixel format is constrained at +// SetPixelFormat time so we can assume 4 bytes per pixel, 8-bit channels, +// and little-endian byte order; arbitrary shifts (R/G/B order) are honoured. +func writePixels(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect) { stride := img.Stride rShift, gShift, bShift := pf.rShift, pf.gShift, pf.bShift - bigEndian := pf.bigEndian != 0 off := 0 for row := r.y; row < r.y+r.h; row++ { p := row*stride + r.x*4 @@ -255,47 +243,13 @@ func writePixelsFast32(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect pixel := (uint32(img.Pix[p]) << rShift) | (uint32(img.Pix[p+1]) << gShift) | (uint32(img.Pix[p+2]) << bShift) - if bigEndian { - binary.BigEndian.PutUint32(dst[off:off+4], pixel) - } else { - binary.LittleEndian.PutUint32(dst[off:off+4], pixel) - } + binary.LittleEndian.PutUint32(dst[off:off+4], pixel) p += 4 off += 4 } } } -func writePixelsGeneric(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect, bytesPerPixel int) { - stride := img.Stride - off := 0 - for row := r.y; row < r.y+r.h; row++ { - for col := r.x; col < r.x+r.w; col++ { - p := row*stride + col*4 - rv := uint32(img.Pix[p]) * uint32(pf.rMax) / 255 - gv := uint32(img.Pix[p+1]) * uint32(pf.gMax) / 255 - bv := uint32(img.Pix[p+2]) * uint32(pf.bMax) / 255 - pixel := (rv << pf.rShift) | (gv << pf.gShift) | (bv << pf.bShift) - emitPixelBytes(dst[off:off+bytesPerPixel], pixel, bytesPerPixel, pf.bigEndian != 0) - off += bytesPerPixel - } - } -} - -func emitPixelBytes(dst []byte, pixel uint32, bytesPerPixel int, bigEndian bool) { - if bigEndian { - for i := range bytesPerPixel { - dst[i] = byte(pixel >> uint((bytesPerPixel-1-i)*8)) - } - return - } - for i := range bytesPerPixel { - dst[i] = byte(pixel >> uint(i*8)) - } -} - - - // diffTiles compares two RGBA images and returns a tile-ordered list of // dirty tiles, one entry per tile. Tile order is top-to-bottom, left-to- // right within each row. The caller decides whether to coalesce or hand @@ -453,33 +407,6 @@ func tileIsUniform(img *image.RGBA, x, y, w, h int) (uint32, bool) { return first, true } -// encodePixel packs an RGBA byte triple into the client's requested pixel -// format, honouring bpp, channel maxes, shifts and endianness. Returns the -// number of bytes written to dst (1..4). -func encodePixel(dst []byte, pf clientPixelFormat, r, g, b byte) int { - bytesPerPixel := max(int(pf.bpp)/8, 1) - var val uint32 - if pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 { - val = (uint32(r) << pf.rShift) | (uint32(g) << pf.gShift) | (uint32(b) << pf.bShift) - } else { - rv := uint32(r) * uint32(pf.rMax) / 255 - gv := uint32(g) * uint32(pf.gMax) / 255 - bv := uint32(b) * uint32(pf.bMax) / 255 - val = (rv << pf.rShift) | (gv << pf.gShift) | (bv << pf.bShift) - } - if pf.bigEndian != 0 { - for i := range bytesPerPixel { - dst[i] = byte(val >> uint((bytesPerPixel-1-i)*8)) - } - } else { - for i := range bytesPerPixel { - dst[i] = byte(val >> uint(i*8)) - } - } - return bytesPerPixel -} - - // tightState holds the per-session JPEG scratch buffer and reused encoders // so per-rect encoding stays alloc-free in the steady state. type tightState struct { diff --git a/client/vnc/server/rfb_bench_test.go b/client/vnc/server/rfb_bench_test.go index 4a011553653..a835d56e95f 100644 --- a/client/vnc/server/rfb_bench_test.go +++ b/client/vnc/server/rfb_bench_test.go @@ -84,26 +84,7 @@ func BenchmarkWritePixels(b *testing.B) { b.SetBytes(int64(r.w * r.h * 4)) b.ReportAllocs() for i := 0; i < b.N; i++ { - writePixels(dst, img, pf, rect{0, 0, r.w, r.h}, 4) - } - }) - } -} - -// BenchmarkWritePixelsScaled forces the general (non-fast) path by using a -// pixel format with non-255 channel maxes. -func BenchmarkWritePixelsScaled(b *testing.B) { - pf := defaultClientPixelFormat() - pf.rMax, pf.gMax, pf.bMax = 31, 63, 31 // 16bpp-ish; exercises the divide path - pf.bpp = 16 - for _, r := range benchRects { - img := makeBenchImage(r.w, r.h, 1) - dst := make([]byte, r.w*r.h*2) - b.Run(r.name, func(b *testing.B) { - b.SetBytes(int64(r.w * r.h * 4)) - b.ReportAllocs() - for i := 0; i < b.N; i++ { - writePixels(dst, img, pf, rect{0, 0, r.w, r.h}, 2) + writePixels(dst, img, pf, rect{0, 0, r.w, r.h}) } }) } diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index d2dd8879771..c5733dc0ed7 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -260,7 +260,10 @@ func (s *session) handleSetPixelFormat() error { if _, err := io.ReadFull(s.conn, buf[:]); err != nil { return fmt.Errorf("read SetPixelFormat: %w", err) } - pf := parsePixelFormat(buf[3:19]) + pf, err := parsePixelFormat(buf[3:19]) + if err != nil { + return err + } s.encMu.Lock() s.pf = pf s.encMu.Unlock() @@ -822,11 +825,8 @@ func drainRequests(ch chan fbRequest) int { } // pfIsTightCompatible reports whether the negotiated client pixel format -// matches Tight's TPIXEL constraint: 32 bpp true colour with 8-bit RGB -// channels at standard shifts (R=16, G=8, B=0). For anything else we fall -// back to Zlib/Hextile/Raw which respect pf in full. +// matches Tight's TPIXEL constraint: standard RGB shifts (R=16, G=8, B=0). +// bpp/endianness/channel-max are already locked at SetPixelFormat time. func pfIsTightCompatible(pf clientPixelFormat) bool { - return pf.bpp == 32 && - pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 && - pf.rShift == 16 && pf.gShift == 8 && pf.bShift == 0 + return pf.rShift == 16 && pf.gShift == 8 && pf.bShift == 0 } From 4f884d9f30eccaf35aefa8892dd92b14946d0a0a Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 09:24:26 +0200 Subject: [PATCH 019/151] Add QEMU Extended Key Event for layout-independent input --- client/vnc/server/input_darwin.go | 24 +++ client/vnc/server/input_uinput_unix.go | 62 +++--- client/vnc/server/input_windows.go | 43 ++++ client/vnc/server/input_x11.go | 26 ++- client/vnc/server/rfb.go | 17 +- client/vnc/server/scancodes.go | 272 +++++++++++++++++++++++++ client/vnc/server/scancodes_darwin.go | 238 ++++++++++++++++++++++ client/vnc/server/scancodes_test.go | 98 +++++++++ client/vnc/server/server.go | 7 + client/vnc/server/session.go | 27 +++ client/vnc/server/stubs.go | 5 + 11 files changed, 775 insertions(+), 44 deletions(-) create mode 100644 client/vnc/server/scancodes.go create mode 100644 client/vnc/server/scancodes_darwin.go create mode 100644 client/vnc/server/scancodes_test.go diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 1b830035d3c..595219745d3 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -352,6 +352,30 @@ func (m *MacInputInjector) InjectKey(keysym uint32, down bool) { if keycode == 0xFFFF { return } + m.postMacKey(src, keycode, down) +} + +// InjectKeyScancode injects using the QEMU scancode, mapped via the +// qemuToMacVK table to Apple's virtual-keycode space. Apple uses an +// entirely different scheme from PC AT scancodes, so the table is the +// authoritative bridge. On miss we fall back to the keysym path. +func (m *MacInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) { + wakeDisplay() + src := ensureEventSource() + if src == 0 { + return + } + vk, ok := qemuToMacVK[scancode] + if !ok { + // Fall back to the keysym path so unmapped keys still work. + m.InjectKey(keysym, down) + return + } + m.postMacKey(src, vk, down) +} + +// postMacKey emits a single key down/up event via Core Graphics. +func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) { event := cgEventCreateKeyboardEvent(src, keycode, down) if event == 0 { return diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_unix.go index 03c1b8997cb..e9a89f3dc9f 100644 --- a/client/vnc/server/input_uinput_unix.go +++ b/client/vnc/server/input_uinput_unix.go @@ -199,6 +199,27 @@ func (u *UInputInjector) InjectKey(keysym uint32, down bool) { if !ok { return } + u.emitKeyCode(code, down) +} + +// InjectKeyScancode injects a press or release using the QEMU scancode. +// uinput speaks Linux KEY_* codes natively, so we map QEMU scancode → +// KEY_* via qemuToLinuxKey. On miss (scancode we don't have a mapping +// for) we fall back to the keysym path, which is exactly the legacy +// behaviour. +func (u *UInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) { + code := qemuScancodeToLinuxKey(scancode) + if code == 0 { + u.InjectKey(keysym, down) + return + } + u.mu.Lock() + defer u.mu.Unlock() + u.emitKeyCode(uint16(code), down) +} + +// emitKeyCode emits one key down/up event plus a sync. Caller holds u.mu. +func (u *UInputInjector) emitKeyCode(code uint16, down bool) { value := int32(0) if down { value = 1 @@ -297,45 +318,8 @@ func (u *UInputInjector) Close() { }) } -// Linux KEY_* codes for the small set we care about. -const ( - keyEsc = 1 - keyMinus = 12 - keyEqual = 13 - keyBackspace = 14 - keyTab = 15 - keyEnter = 28 - keyLeftCtrl = 29 - keySemicolon = 39 - keyApostrophe = 40 - keyGrave = 41 - keyLeftShift = 42 - keyBackslash = 43 - keyComma = 51 - keyDot = 52 - keySlash = 53 - keyRightShift = 54 - keyLeftAlt = 56 - keySpace = 57 - keyCapsLock = 58 - keyF1 = 59 - keyLeftBracket = 26 - keyRightBracket = 27 - keyHome = 102 - keyUp = 103 - keyPageUp = 104 - keyLeft = 105 - keyRight = 106 - keyEnd = 107 - keyDown = 108 - keyPageDown = 109 - keyInsert = 110 - keyDelete = 111 - keyRightCtrl = 97 - keyRightAlt = 100 - keyLeftMeta = 125 - keyRightMeta = 126 -) +// Linux KEY_* codes live in scancodes.go (shared with the QEMU scancode +// path). Don't duplicate them here. // buildUInputKeymap returns every linux KEY_ code we want the virtual // device to advertise during UI_SET_KEYBIT. Order doesn't matter. diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index 0dea1f343bc..9f6af408980 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -106,9 +106,11 @@ const sasEventName = `Global\NetBirdVNC_SAS` type inputCmd struct { isKey bool + isScancode bool isClipboard bool isType bool keysym uint32 + scancode uint32 down bool buttonMask uint8 x, y int @@ -187,6 +189,8 @@ func (w *WindowsInputInjector) dispatch(cmd inputCmd) { w.doSetClipboard(cmd.clipText) case cmd.isType: w.typeUnicodeText(cmd.clipText) + case cmd.isScancode: + w.doInjectKeyScancode(cmd.scancode, cmd.keysym, cmd.down) case cmd.isKey: w.doInjectKey(cmd.keysym, cmd.down) default: @@ -199,6 +203,19 @@ func (w *WindowsInputInjector) InjectKey(keysym uint32, down bool) { w.tryEnqueue(inputCmd{isKey: true, keysym: keysym, down: down}) } +// InjectKeyScancode queues a raw-scancode key event. PC AT Set 1 maps +// directly onto what SendInput's KEYEVENTF_SCANCODE flag wants, so the +// only translation is splitting the optional 0xE0 prefix off into the +// KEYEVENTF_EXTENDEDKEY flag. keysym is the noVNC-provided fallback we +// reach for if the scancode is zero. +func (w *WindowsInputInjector) InjectKeyScancode(scancode uint32, keysym uint32, down bool) { + if scancode == 0 { + w.InjectKey(keysym, down) + return + } + w.tryEnqueue(inputCmd{isScancode: true, scancode: scancode, keysym: keysym, down: down}) +} + // InjectPointer queues a pointer event for injection on the input desktop // thread. Pointer events coalesce: when the channel is full (slow desktop // switch, hung SendInput), drop the new sample so the read loop never @@ -207,6 +224,32 @@ func (w *WindowsInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, se w.tryEnqueue(inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH}) } +// doInjectKeyScancode injects a key event using the QEMU scancode directly, +// bypassing the keysym→VK lookup. Windows accepts PC AT Set 1 scancodes +// natively via KEYEVENTF_SCANCODE, so the only work is splitting the +// optional 0xE0 prefix off into the EXTENDEDKEY flag and tracking +// modifier state for the SAS Ctrl+Alt+Del shortcut. +func (w *WindowsInputInjector) doInjectKeyScancode(scancode, keysym uint32, down bool) { + switch keysym { + case 0xffe3, 0xffe4: + w.ctrlDown = down + case 0xffe9, 0xffea: + w.altDown = down + } + if (keysym == 0xff9f || keysym == 0xffff) && w.ctrlDown && w.altDown && down { + signalSAS() + return + } + flags := uint32(keyeventfScanCode) + if !down { + flags |= keyeventfKeyUp + } + if qemuScancodeIsExtended(scancode) { + flags |= keyeventfExtendedKey + } + sendKeyInput(0, qemuScancodeLowByte(scancode), flags) +} + func (w *WindowsInputInjector) doInjectKey(keysym uint32, down bool) { switch keysym { case 0xffe3, 0xffe4: diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index 6696552ee01..ada791ce8e5 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -74,14 +74,38 @@ func (x *X11InputInjector) InjectKey(keysym uint32, down bool) { if keycode == 0 { return } + x.fakeKeyEvent(keycode, down) +} +// InjectKeyScancode injects using the QEMU scancode by translating to a +// Linux KEY_ code and then to an X11 keycode (KEY_* + xkbKeycodeOffset). +// On a server running a standard XKB keymap this is layout-independent: +// the scancode names the physical key, the server's layout determines the +// resulting character. Falls back to the keysym path when the scancode +// has no Linux mapping. +func (x *X11InputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) { + linuxKey := qemuScancodeToLinuxKey(scancode) + if linuxKey == 0 { + x.InjectKey(keysym, down) + return + } + x.fakeKeyEvent(byte(linuxKey+xkbKeycodeOffset), down) +} + +// xkbKeycodeOffset is the per-server constant offset between Linux KEY_* +// event codes and the X server's keycode space under XKB. The X protocol +// reserves keycodes 0..7 for internal use, so any normal XKB keymap +// starts at 8 (KEY_ESC=1 → X keycode 9, KEY_A=30 → X keycode 38, etc.). +const xkbKeycodeOffset = 8 + +// fakeKeyEvent sends an XTest FakeInput for a press or release. +func (x *X11InputInjector) fakeKeyEvent(keycode byte, down bool) { var eventType byte if down { eventType = xproto.KeyPress } else { eventType = xproto.KeyRelease } - xtest.FakeInput(x.conn, eventType, keycode, 0, x.root, 0, 0, 0) } diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index e5eafada2cd..136b5599b07 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -29,6 +29,14 @@ const ( clientKeyEvent = 4 clientPointerEvent = 5 clientCutText = 6 + // clientQEMUMessage is the QEMU vendor message wrapper. The subtype + // byte that follows selects the actual operation; we only handle the + // Extended Key Event (subtype 0) which carries a hardware scancode in + // addition to the X11 keysym. Layout-independent key entry. + clientQEMUMessage = 255 + + // QEMU Extended Key Event subtype carried inside clientQEMUMessage. + qemuSubtypeExtendedKeyEvent = 0 // clientNetbirdTypeText is a NetBird-specific message that asks the // server to synthesize the given text as keystrokes regardless of the @@ -53,10 +61,11 @@ const ( // Pseudo-encodings carried over wire as rects with a negative // encoding value. The client advertises supported optional protocol // extensions by listing these in SetEncodings. - pseudoEncDesktopSize = -223 - pseudoEncLastRect = -224 - pseudoEncDesktopName = -307 - pseudoEncExtendedDesktopSize = -308 + pseudoEncDesktopSize = -223 + pseudoEncLastRect = -224 + pseudoEncQEMUExtendedKeyEvent = -258 + pseudoEncDesktopName = -307 + pseudoEncExtendedDesktopSize = -308 // Tight compression-control byte top nibble. Stream-reset bits 0-3 // (one per zlib stream) are unused while we run a single stream. diff --git a/client/vnc/server/scancodes.go b/client/vnc/server/scancodes.go new file mode 100644 index 00000000000..2016bbc4517 --- /dev/null +++ b/client/vnc/server/scancodes.go @@ -0,0 +1,272 @@ +package server + +// QEMU Extended Key Event carries hardware scancodes encoded as PC AT Set 1. +// Single-byte codes cover the standard keys; the "extended" prefix 0xE0 is +// merged into the high byte (so 0xE048 is the extended-Up arrow). This file +// translates those scancodes into the per-platform identifiers each input +// backend wants: +// +// - Linux uinput wants Linux KEY_* codes (defined in +// linux/input-event-codes.h). uinput is what we use for virtual Xvfb +// sessions on Linux. +// - X11 XTest wants XKB keycodes, which on a standard layout equal +// Linux KEY_* + 8 (the per-server offset between the Linux event code +// and the X server's keycode space). +// - Windows SendInput accepts the PC AT scancode directly via +// KEYEVENTF_SCANCODE, so no mapping table is needed there; the +// extended-key bit is set when the QEMU scancode high byte is 0xE0. +// - macOS CGEventCreateKeyboardEvent takes a "virtual keycode" from +// Apple's HID set, which is unrelated to PC AT and needs its own +// table (see qemuToMacVK in input_darwin.go). +// +// Linux KEY_* codes. Only the ones we reference, since the full +// linux/input-event-codes.h list isn't useful here. Naming mirrors the +// existing constants in input_uinput_unix.go (mixed case, no underscores). +const ( + keyEsc = 1 + key1 = 2 + key2 = 3 + key3 = 4 + key4 = 5 + key5 = 6 + key6 = 7 + key7 = 8 + key8 = 9 + key9 = 10 + key0 = 11 + keyMinus = 12 + keyEqual = 13 + keyBackspace = 14 + keyTab = 15 + keyQ = 16 + keyW = 17 + keyE = 18 + keyR = 19 + keyT = 20 + keyY = 21 + keyU = 22 + keyI = 23 + keyO = 24 + keyP = 25 + keyLeftBracket = 26 + keyRightBracket = 27 + keyEnter = 28 + keyLeftCtrl = 29 + keyA = 30 + keyS = 31 + keyD = 32 + keyF = 33 + keyG = 34 + keyH = 35 + keyJ = 36 + keyK = 37 + keyL = 38 + keySemicolon = 39 + keyApostrophe = 40 + keyGrave = 41 + keyLeftShift = 42 + keyBackslash = 43 + keyZ = 44 + keyX = 45 + keyC = 46 + keyV = 47 + keyB = 48 + keyN = 49 + keyM = 50 + keyComma = 51 + keyDot = 52 + keySlash = 53 + keyRightShift = 54 + keyKPAsterisk = 55 + keyLeftAlt = 56 + keySpace = 57 + keyCapsLock = 58 + keyF1 = 59 + keyF2 = 60 + keyF3 = 61 + keyF4 = 62 + keyF5 = 63 + keyF6 = 64 + keyF7 = 65 + keyF8 = 66 + keyF9 = 67 + keyF10 = 68 + keyNumLock = 69 + keyScrollLock = 70 + keyKP7 = 71 + keyKP8 = 72 + keyKP9 = 73 + keyKPMinus = 74 + keyKP4 = 75 + keyKP5 = 76 + keyKP6 = 77 + keyKPPlus = 78 + keyKP1 = 79 + keyKP2 = 80 + keyKP3 = 81 + keyKP0 = 82 + keyKPDot = 83 + key102nd = 86 + keyF11 = 87 + keyF12 = 88 + keyKPEnter = 96 + keyRightCtrl = 97 + keyKPSlash = 98 + keySysRq = 99 + keyRightAlt = 100 + keyHome = 102 + keyUp = 103 + keyPageUp = 104 + keyLeft = 105 + keyRight = 106 + keyEnd = 107 + keyDown = 108 + keyPageDown = 109 + keyInsert = 110 + keyDelete = 111 + keyMute = 113 + keyVolumeDown = 114 + keyVolumeUp = 115 + keyLeftMeta = 125 + keyRightMeta = 126 + keyCompose = 127 +) + +// qemuToLinuxKey maps the PC AT Set 1 scancode QEMU sends to a Linux KEY_* +// code. The high byte 0xE0 marks "extended" scancodes (arrows, the right- +// side modifier keys, keypad enter/divide, browser keys, etc.). +// +// Keep this table dense so a reviewer sees the whole keyboard at a glance, +// and so adding a new key is a single line. +var qemuToLinuxKey = map[uint32]int{ + // Single-byte (non-extended) scancodes. + 0x01: keyEsc, + 0x02: key1, + 0x03: key2, + 0x04: key3, + 0x05: key4, + 0x06: key5, + 0x07: key6, + 0x08: key7, + 0x09: key8, + 0x0A: key9, + 0x0B: key0, + 0x0C: keyMinus, + 0x0D: keyEqual, + 0x0E: keyBackspace, + 0x0F: keyTab, + 0x10: keyQ, + 0x11: keyW, + 0x12: keyE, + 0x13: keyR, + 0x14: keyT, + 0x15: keyY, + 0x16: keyU, + 0x17: keyI, + 0x18: keyO, + 0x19: keyP, + 0x1A: keyLeftBracket, + 0x1B: keyRightBracket, + 0x1C: keyEnter, + 0x1D: keyLeftCtrl, + 0x1E: keyA, + 0x1F: keyS, + 0x20: keyD, + 0x21: keyF, + 0x22: keyG, + 0x23: keyH, + 0x24: keyJ, + 0x25: keyK, + 0x26: keyL, + 0x27: keySemicolon, + 0x28: keyApostrophe, + 0x29: keyGrave, + 0x2A: keyLeftShift, + 0x2B: keyBackslash, + 0x2C: keyZ, + 0x2D: keyX, + 0x2E: keyC, + 0x2F: keyV, + 0x30: keyB, + 0x31: keyN, + 0x32: keyM, + 0x33: keyComma, + 0x34: keyDot, + 0x35: keySlash, + 0x36: keyRightShift, + 0x37: keyKPAsterisk, + 0x38: keyLeftAlt, + 0x39: keySpace, + 0x3A: keyCapsLock, + 0x3B: keyF1, + 0x3C: keyF2, + 0x3D: keyF3, + 0x3E: keyF4, + 0x3F: keyF5, + 0x40: keyF6, + 0x41: keyF7, + 0x42: keyF8, + 0x43: keyF9, + 0x44: keyF10, + 0x45: keyNumLock, + 0x46: keyScrollLock, + 0x47: keyKP7, + 0x48: keyKP8, + 0x49: keyKP9, + 0x4A: keyKPMinus, + 0x4B: keyKP4, + 0x4C: keyKP5, + 0x4D: keyKP6, + 0x4E: keyKPPlus, + 0x4F: keyKP1, + 0x50: keyKP2, + 0x51: keyKP3, + 0x52: keyKP0, + 0x53: keyKPDot, + 0x56: key102nd, + 0x57: keyF11, + 0x58: keyF12, + + // Extended (0xE0-prefixed) scancodes. + 0xE01C: keyKPEnter, + 0xE01D: keyRightCtrl, + 0xE020: keyMute, + 0xE02E: keyVolumeDown, + 0xE030: keyVolumeUp, + 0xE035: keyKPSlash, + 0xE037: keySysRq, // PrintScreen + 0xE038: keyRightAlt, + 0xE047: keyHome, + 0xE048: keyUp, + 0xE049: keyPageUp, + 0xE04B: keyLeft, + 0xE04D: keyRight, + 0xE04F: keyEnd, + 0xE050: keyDown, + 0xE051: keyPageDown, + 0xE052: keyInsert, + 0xE053: keyDelete, + 0xE05B: keyLeftMeta, + 0xE05C: keyRightMeta, + 0xE05D: keyCompose, +} + +// qemuScancodeToLinuxKey is the lookup the uinput and X11 paths use. +// Returns 0 (which Linux treats as KEY_RESERVED) when the scancode has no +// mapping, signalling "fall back to the keysym path". +func qemuScancodeToLinuxKey(scancode uint32) int { + return qemuToLinuxKey[scancode] +} + +// qemuScancodeIsExtended reports whether a QEMU scancode is in the +// 0xE0-prefixed extended range. Used by Windows SendInput to set the +// KEYEVENTF_EXTENDEDKEY flag. +func qemuScancodeIsExtended(scancode uint32) bool { + return scancode&0xFF00 == 0xE000 +} + +// qemuScancodeLowByte returns the byte SendInput's wScan field actually +// stores: the low byte of the scancode regardless of any extended prefix. +func qemuScancodeLowByte(scancode uint32) uint16 { + return uint16(scancode & 0xFF) +} diff --git a/client/vnc/server/scancodes_darwin.go b/client/vnc/server/scancodes_darwin.go new file mode 100644 index 00000000000..7d9fd9de8b1 --- /dev/null +++ b/client/vnc/server/scancodes_darwin.go @@ -0,0 +1,238 @@ +//go:build darwin && !ios + +package server + +// Apple keyboard virtual-key codes used with CGEventCreateKeyboardEvent. +// These are the kVK_ANSI_* / kVK_* values from Apple's +// HIToolbox/Events.h; reproduced here so we don't need to drag in the +// HIToolbox framework just for the constants. +const ( + macKeyA uint16 = 0x00 + macKeyS uint16 = 0x01 + macKeyD uint16 = 0x02 + macKeyF uint16 = 0x03 + macKeyH uint16 = 0x04 + macKeyG uint16 = 0x05 + macKeyZ uint16 = 0x06 + macKeyX uint16 = 0x07 + macKeyC uint16 = 0x08 + macKeyV uint16 = 0x09 + macKeyNonUSBackslash uint16 = 0x0A // ISO_Section / 102nd + macKeyB uint16 = 0x0B + macKeyQ uint16 = 0x0C + macKeyW uint16 = 0x0D + macKeyE uint16 = 0x0E + macKeyR uint16 = 0x0F + macKeyY uint16 = 0x10 + macKeyT uint16 = 0x11 + macKey1 uint16 = 0x12 + macKey2 uint16 = 0x13 + macKey3 uint16 = 0x14 + macKey4 uint16 = 0x15 + macKey6 uint16 = 0x16 + macKey5 uint16 = 0x17 + macKeyEqual uint16 = 0x18 + macKey9 uint16 = 0x19 + macKey7 uint16 = 0x1A + macKeyMinus uint16 = 0x1B + macKey8 uint16 = 0x1C + macKey0 uint16 = 0x1D + macKeyRightBracket uint16 = 0x1E + macKeyO uint16 = 0x1F + macKeyU uint16 = 0x20 + macKeyLeftBracket uint16 = 0x21 + macKeyI uint16 = 0x22 + macKeyP uint16 = 0x23 + macKeyReturn uint16 = 0x24 + macKeyL uint16 = 0x25 + macKeyJ uint16 = 0x26 + macKeyApostrophe uint16 = 0x27 + macKeyK uint16 = 0x28 + macKeySemicolon uint16 = 0x29 + macKeyBackslash uint16 = 0x2A + macKeyComma uint16 = 0x2B + macKeySlash uint16 = 0x2C + macKeyN uint16 = 0x2D + macKeyM uint16 = 0x2E + macKeyPeriod uint16 = 0x2F + macKeyTab uint16 = 0x30 + macKeySpace uint16 = 0x31 + macKeyGrave uint16 = 0x32 + macKeyDelete uint16 = 0x33 // Backspace + macKeyEscape uint16 = 0x35 + macKeyCommand uint16 = 0x37 + macKeyShift uint16 = 0x38 + macKeyCapsLock uint16 = 0x39 + macKeyOption uint16 = 0x3A // Alt + macKeyControl uint16 = 0x3B + macKeyRightShift uint16 = 0x3C + macKeyRightOption uint16 = 0x3D + macKeyRightControl uint16 = 0x3E + macKeyFunction uint16 = 0x3F + macKeyF17 uint16 = 0x40 + macKeyKPDecimal uint16 = 0x41 + macKeyKPMultiply uint16 = 0x43 + macKeyKPPlus uint16 = 0x45 + macKeyKPClear uint16 = 0x47 // numlock + macKeyVolumeUp uint16 = 0x48 + macKeyVolumeDown uint16 = 0x49 + macKeyMute uint16 = 0x4A + macKeyKPDivide uint16 = 0x4B + macKeyKPEnter uint16 = 0x4C + macKeyKPMinus uint16 = 0x4E + macKeyF18 uint16 = 0x4F + macKeyF19 uint16 = 0x50 + macKeyKPEqual uint16 = 0x51 + macKeyKP0 uint16 = 0x52 + macKeyKP1 uint16 = 0x53 + macKeyKP2 uint16 = 0x54 + macKeyKP3 uint16 = 0x55 + macKeyKP4 uint16 = 0x56 + macKeyKP5 uint16 = 0x57 + macKeyKP6 uint16 = 0x58 + macKeyKP7 uint16 = 0x59 + macKeyF20 uint16 = 0x5A + macKeyKP8 uint16 = 0x5B + macKeyKP9 uint16 = 0x5C + macKeyF5 uint16 = 0x60 + macKeyF6 uint16 = 0x61 + macKeyF7 uint16 = 0x62 + macKeyF3 uint16 = 0x63 + macKeyF8 uint16 = 0x64 + macKeyF9 uint16 = 0x65 + macKeyF11 uint16 = 0x67 + macKeyF13 uint16 = 0x69 // PrintScreen on most layouts + macKeyF16 uint16 = 0x6A + macKeyF14 uint16 = 0x6B + macKeyF10 uint16 = 0x6D + macKeyF12 uint16 = 0x6F + macKeyF15 uint16 = 0x71 + macKeyHelp uint16 = 0x72 // Insert on PC keyboards + macKeyHome uint16 = 0x73 + macKeyPageUp uint16 = 0x74 + macKeyForwardDelete uint16 = 0x75 + macKeyF4 uint16 = 0x76 + macKeyEnd uint16 = 0x77 + macKeyF2 uint16 = 0x78 + macKeyPageDown uint16 = 0x79 + macKeyF1 uint16 = 0x7A + macKeyLeft uint16 = 0x7B + macKeyRight uint16 = 0x7C + macKeyDown uint16 = 0x7D + macKeyUp uint16 = 0x7E +) + +// qemuToMacVK maps PC AT Set 1 scancodes (as QEMU emits them, with the +// 0xE0 prefix merged into the high byte) onto Apple virtual-key codes. +// Layout-independent: the scancode names the physical key, the user's +// active keyboard layout on the Mac decides what the key produces. +var qemuToMacVK = map[uint32]uint16{ + // Single-byte (non-extended). + 0x01: macKeyEscape, + 0x02: macKey1, + 0x03: macKey2, + 0x04: macKey3, + 0x05: macKey4, + 0x06: macKey5, + 0x07: macKey6, + 0x08: macKey7, + 0x09: macKey8, + 0x0A: macKey9, + 0x0B: macKey0, + 0x0C: macKeyMinus, + 0x0D: macKeyEqual, + 0x0E: macKeyDelete, // PC Backspace -> mac "Delete" + 0x0F: macKeyTab, + 0x10: macKeyQ, + 0x11: macKeyW, + 0x12: macKeyE, + 0x13: macKeyR, + 0x14: macKeyT, + 0x15: macKeyY, + 0x16: macKeyU, + 0x17: macKeyI, + 0x18: macKeyO, + 0x19: macKeyP, + 0x1A: macKeyLeftBracket, + 0x1B: macKeyRightBracket, + 0x1C: macKeyReturn, + 0x1D: macKeyControl, + 0x1E: macKeyA, + 0x1F: macKeyS, + 0x20: macKeyD, + 0x21: macKeyF, + 0x22: macKeyG, + 0x23: macKeyH, + 0x24: macKeyJ, + 0x25: macKeyK, + 0x26: macKeyL, + 0x27: macKeySemicolon, + 0x28: macKeyApostrophe, + 0x29: macKeyGrave, + 0x2A: macKeyShift, + 0x2B: macKeyBackslash, + 0x2C: macKeyZ, + 0x2D: macKeyX, + 0x2E: macKeyC, + 0x2F: macKeyV, + 0x30: macKeyB, + 0x31: macKeyN, + 0x32: macKeyM, + 0x33: macKeyComma, + 0x34: macKeyPeriod, + 0x35: macKeySlash, + 0x36: macKeyRightShift, + 0x37: macKeyKPMultiply, + 0x38: macKeyOption, // Left Alt -> Option + 0x39: macKeySpace, + 0x3A: macKeyCapsLock, + 0x3B: macKeyF1, + 0x3C: macKeyF2, + 0x3D: macKeyF3, + 0x3E: macKeyF4, + 0x3F: macKeyF5, + 0x40: macKeyF6, + 0x41: macKeyF7, + 0x42: macKeyF8, + 0x43: macKeyF9, + 0x44: macKeyF10, + 0x45: macKeyKPClear, // PC NumLock -> mac Clear + 0x47: macKeyKP7, + 0x48: macKeyKP8, + 0x49: macKeyKP9, + 0x4A: macKeyKPMinus, + 0x4B: macKeyKP4, + 0x4C: macKeyKP5, + 0x4D: macKeyKP6, + 0x4E: macKeyKPPlus, + 0x4F: macKeyKP1, + 0x50: macKeyKP2, + 0x51: macKeyKP3, + 0x52: macKeyKP0, + 0x53: macKeyKPDecimal, + 0x56: macKeyNonUSBackslash, + 0x57: macKeyF11, + 0x58: macKeyF12, + + // Extended (0xE0 prefix). + 0xE01C: macKeyKPEnter, + 0xE01D: macKeyRightControl, + 0xE020: macKeyMute, + 0xE02E: macKeyVolumeDown, + 0xE030: macKeyVolumeUp, + 0xE035: macKeyKPDivide, + 0xE037: macKeyF13, // PrintScreen + 0xE038: macKeyRightOption, + 0xE047: macKeyHome, + 0xE048: macKeyUp, + 0xE049: macKeyPageUp, + 0xE04B: macKeyLeft, + 0xE04D: macKeyRight, + 0xE04F: macKeyEnd, + 0xE050: macKeyDown, + 0xE051: macKeyPageDown, + 0xE052: macKeyHelp, // PC Insert -> mac Help + 0xE053: macKeyForwardDelete, + 0xE05B: macKeyCommand, // Left Windows -> Command + 0xE05C: macKeyCommand, // Right Windows -> Command (no separate code) +} diff --git a/client/vnc/server/scancodes_test.go b/client/vnc/server/scancodes_test.go new file mode 100644 index 00000000000..1c6beafa616 --- /dev/null +++ b/client/vnc/server/scancodes_test.go @@ -0,0 +1,98 @@ +package server + +import "testing" + +func TestQemuScancodeToLinuxKey_KnownLetters(t *testing.T) { + // Spot-check a few familiar letter keys against the Linux KEY_* + // values they're supposed to land on. + tests := []struct { + name string + scancode uint32 + want int + }{ + {"A", 0x1E, keyA}, + {"S", 0x1F, keyS}, + {"D", 0x20, keyD}, + {"Q", 0x10, keyQ}, + {"Z", 0x2C, keyZ}, + {"1", 0x02, key1}, + {"Esc", 0x01, keyEsc}, + {"Tab", 0x0F, keyTab}, + {"Space", 0x39, keySpace}, + {"LeftShift", 0x2A, keyLeftShift}, + } + for _, tc := range tests { + got := qemuScancodeToLinuxKey(tc.scancode) + if got != tc.want { + t.Errorf("%s: scancode 0x%X => %d, want %d", tc.name, tc.scancode, got, tc.want) + } + } +} + +func TestQemuScancodeToLinuxKey_Extended(t *testing.T) { + // Extended (0xE0-prefixed) scancodes for arrow + navigation cluster. + tests := []struct { + name string + scancode uint32 + want int + }{ + {"Up", 0xE048, keyUp}, + {"Down", 0xE050, keyDown}, + {"Left", 0xE04B, keyLeft}, + {"Right", 0xE04D, keyRight}, + {"Home", 0xE047, keyHome}, + {"End", 0xE04F, keyEnd}, + {"PageUp", 0xE049, keyPageUp}, + {"PageDown", 0xE051, keyPageDown}, + {"Insert", 0xE052, keyInsert}, + {"Delete", 0xE053, keyDelete}, + {"RightCtrl", 0xE01D, keyRightCtrl}, + {"RightAlt", 0xE038, keyRightAlt}, + {"KPEnter", 0xE01C, keyKPEnter}, + {"KPSlash", 0xE035, keyKPSlash}, + } + for _, tc := range tests { + got := qemuScancodeToLinuxKey(tc.scancode) + if got != tc.want { + t.Errorf("%s: scancode 0x%X => %d, want %d", tc.name, tc.scancode, got, tc.want) + } + } +} + +func TestQemuScancodeToLinuxKey_Miss(t *testing.T) { + // 0xE0FF is in the extended range but not a real key. Must return 0 + // so the caller can fall back to the keysym path. + if got := qemuScancodeToLinuxKey(0xE0FF); got != 0 { + t.Errorf("unknown scancode should miss: got %d, want 0", got) + } + if got := qemuScancodeToLinuxKey(0xFF); got != 0 { + t.Errorf("unknown non-extended scancode should miss: got %d, want 0", got) + } +} + +func TestQemuScancodeIsExtended(t *testing.T) { + cases := []struct { + scancode uint32 + want bool + }{ + {0x1E, false}, + {0xE048, true}, + {0xE000, true}, + {0xFF, false}, + {0xE0FF, true}, + } + for _, tc := range cases { + if got := qemuScancodeIsExtended(tc.scancode); got != tc.want { + t.Errorf("isExtended(0x%X) = %v, want %v", tc.scancode, got, tc.want) + } + } +} + +func TestQemuScancodeLowByte(t *testing.T) { + if got := qemuScancodeLowByte(0xE048); got != 0x48 { + t.Errorf("lowByte(0xE048) = 0x%X, want 0x48", got) + } + if got := qemuScancodeLowByte(0x1E); got != 0x1E { + t.Errorf("lowByte(0x1E) = 0x%X, want 0x1E", got) + } +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 237a45aa603..64d8e6e7eef 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -79,6 +79,13 @@ var errFrameUnchanged = errors.New("frame unchanged") type InputInjector interface { // InjectKey simulates a key press or release. keysym is an X11 KeySym. InjectKey(keysym uint32, down bool) + // InjectKeyScancode simulates a key press or release using the QEMU + // scancode (PC AT set 1, high byte 0xE0 for extended keys). Layout- + // independent: the server's local keyboard layout decides what + // character the key produces. Implementations should fall back to + // InjectKey(keysym, down) when they don't have a scancode mapping + // for the given code; that's strictly no worse than the legacy path. + InjectKeyScancode(scancode uint32, keysym uint32, down bool) // InjectPointer simulates mouse movement and button state. InjectPointer(buttonMask uint8, x, y, serverW, serverH int) // SetClipboard sets the system clipboard to the given text. diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index c5733dc0ed7..cd4e89cd2d3 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -57,6 +57,7 @@ type session struct { clientSupportsExtendedDesktopSize bool clientSupportsDesktopName bool clientSupportsLastRect bool + clientSupportsQEMUKey bool // prevFrame, curFrame and idleFrames live on the encoder goroutine and // must not be touched elsewhere. curFrame holds a session-owned copy of // the capturer's latest frame so the encoder works on a stable buffer @@ -241,6 +242,8 @@ func (s *session) messageLoop() error { err = s.handlePointerEvent() case clientCutText: err = s.handleCutText() + case clientQEMUMessage: + err = s.handleQEMUMessage() case clientNetbirdTypeText: err = s.handleTypeText() default: @@ -311,6 +314,9 @@ func (s *session) handleSetEncodings() error { case pseudoEncLastRect: s.clientSupportsLastRect = true encs = append(encs, "last-rect") + case pseudoEncQEMUExtendedKeyEvent: + s.clientSupportsQEMUKey = true + encs = append(encs, "qemu-key") case encTight: s.useTight = true if s.tight == nil { @@ -749,6 +755,27 @@ func (s *session) handleKeyEvent() error { return nil } +// handleQEMUMessage parses one QEMU vendor message. Today we only handle +// subtype 0 (Extended Key Event); the message itself is 12 bytes total so +// reading 11 more after the type byte covers the layout regardless of +// subtype, and unknown subtypes are dropped without aborting the session. +func (s *session) handleQEMUMessage() error { + var data [11]byte // subtype(1) + down(2) + keysym(4) + keycode(4) + if _, err := io.ReadFull(s.conn, data[:]); err != nil { + return fmt.Errorf("read QEMU message: %w", err) + } + subtype := data[0] + if subtype != qemuSubtypeExtendedKeyEvent { + s.log.Tracef("ignoring QEMU subtype %d", subtype) + return nil + } + down := binary.BigEndian.Uint16(data[1:3]) != 0 + keysym := binary.BigEndian.Uint32(data[3:7]) + scancode := binary.BigEndian.Uint32(data[7:11]) + s.injector.InjectKeyScancode(scancode, keysym, down) + return nil +} + func (s *session) handlePointerEvent() error { var data [5]byte if _, err := io.ReadFull(s.conn, data[:]); err != nil { diff --git a/client/vnc/server/stubs.go b/client/vnc/server/stubs.go index 0ac44b50694..d8441751a91 100644 --- a/client/vnc/server/stubs.go +++ b/client/vnc/server/stubs.go @@ -27,6 +27,11 @@ func (s *StubInputInjector) InjectKey(_ uint32, _ bool) { // no-op } +// InjectKeyScancode is a no-op on unsupported platforms. +func (s *StubInputInjector) InjectKeyScancode(_ uint32, _ uint32, _ bool) { + // no-op +} + // InjectPointer is a no-op on unsupported platforms. func (s *StubInputInjector) InjectPointer(_ uint8, _, _, _, _ int) { // no-op From da37a289516b249c8147b74d4f44a51446d830bf Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 09:48:06 +0200 Subject: [PATCH 020/151] Exclude VNC server from js, ios, and android builds --- client/internal/engine_vnc.go | 2 ++ client/internal/engine_vnc_stub.go | 18 ++++++++++-------- client/vnc/server/capture_fb_unix.go | 2 +- client/vnc/server/capture_x11.go | 2 +- client/vnc/server/coalesce_test.go | 2 ++ client/vnc/server/copyrect.go | 2 ++ client/vnc/server/copyrect_test.go | 2 ++ client/vnc/server/input_uinput_unix.go | 2 +- client/vnc/server/input_x11.go | 2 +- client/vnc/server/pseudo_encodings_test.go | 2 ++ client/vnc/server/rfb.go | 2 ++ client/vnc/server/rfb_bench_test.go | 2 ++ client/vnc/server/scancodes.go | 2 ++ client/vnc/server/scancodes_test.go | 2 ++ client/vnc/server/server.go | 2 ++ client/vnc/server/server_stub.go | 21 --------------------- client/vnc/server/server_test.go | 2 ++ client/vnc/server/server_x11.go | 2 +- client/vnc/server/session.go | 2 ++ client/vnc/server/stubs.go | 2 ++ client/vnc/server/swizzle.go | 2 ++ client/vnc/server/tight_test.go | 2 ++ client/vnc/server/virtual_x11.go | 2 +- 23 files changed, 46 insertions(+), 35 deletions(-) delete mode 100644 client/vnc/server/server_stub.go diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index a7d9b1012e0..d162f27cbb5 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package internal import ( diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go index 8ef16803d55..b1d0ac26269 100644 --- a/client/internal/engine_vnc_stub.go +++ b/client/internal/engine_vnc_stub.go @@ -1,13 +1,15 @@ -//go:build (!windows && !darwin && !freebsd && !(linux && !android)) || (darwin && ios) +//go:build js || ios || android package internal -import vncserver "github.com/netbirdio/netbird/client/vnc/server" +import ( + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) -func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { - return nil, nil, false -} +type vncServer interface{} -func vncNeedsServiceMode() bool { - return false -} +func (e *Engine) updateVNC(_ *mgmProto.SSHConfig) error { return nil } + +func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) {} + +func (e *Engine) stopVNCServer() error { return nil } diff --git a/client/vnc/server/capture_fb_unix.go b/client/vnc/server/capture_fb_unix.go index da57dcc2c05..01981371f92 100644 --- a/client/vnc/server/capture_fb_unix.go +++ b/client/vnc/server/capture_fb_unix.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || freebsd +//go:build unix && !darwin && !ios && !android package server diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index d108aada16a..fd3eb585916 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || freebsd +//go:build unix && !darwin && !ios && !android package server diff --git a/client/vnc/server/coalesce_test.go b/client/vnc/server/coalesce_test.go index f37bc9eee89..08632922bd5 100644 --- a/client/vnc/server/coalesce_test.go +++ b/client/vnc/server/coalesce_test.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/copyrect.go b/client/vnc/server/copyrect.go index b0356a37e82..97d2756ae48 100644 --- a/client/vnc/server/copyrect.go +++ b/client/vnc/server/copyrect.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/copyrect_test.go b/client/vnc/server/copyrect_test.go index 8b5691b56c3..51f0d23b5e4 100644 --- a/client/vnc/server/copyrect_test.go +++ b/client/vnc/server/copyrect_test.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_unix.go index e9a89f3dc9f..4b70594f28e 100644 --- a/client/vnc/server/input_uinput_unix.go +++ b/client/vnc/server/input_uinput_unix.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || freebsd +//go:build unix && !darwin && !ios && !android package server diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index ada791ce8e5..60a325806e3 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || freebsd +//go:build unix && !darwin && !ios && !android package server diff --git a/client/vnc/server/pseudo_encodings_test.go b/client/vnc/server/pseudo_encodings_test.go index a319227c586..965e82432db 100644 --- a/client/vnc/server/pseudo_encodings_test.go +++ b/client/vnc/server/pseudo_encodings_test.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import "testing" diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 136b5599b07..57f9c21bc24 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/rfb_bench_test.go b/client/vnc/server/rfb_bench_test.go index a835d56e95f..fb0f6ac75a6 100644 --- a/client/vnc/server/rfb_bench_test.go +++ b/client/vnc/server/rfb_bench_test.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/scancodes.go b/client/vnc/server/scancodes.go index 2016bbc4517..54db42a6352 100644 --- a/client/vnc/server/scancodes.go +++ b/client/vnc/server/scancodes.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server // QEMU Extended Key Event carries hardware scancodes encoded as PC AT Set 1. diff --git a/client/vnc/server/scancodes_test.go b/client/vnc/server/scancodes_test.go index 1c6beafa616..5479aa66f2c 100644 --- a/client/vnc/server/scancodes_test.go +++ b/client/vnc/server/scancodes_test.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import "testing" diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 64d8e6e7eef..1bda413e8ac 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/server_stub.go b/client/vnc/server/server_stub.go deleted file mode 100644 index e6ace1a2737..00000000000 --- a/client/vnc/server/server_stub.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build (!windows && !darwin && !freebsd && !(linux && !android)) || (darwin && ios) - -package server - -func (s *Server) platformInit() { - // no-op on unsupported platforms -} - -// serviceAcceptLoop is not supported on non-Windows platforms. -func (s *Server) serviceAcceptLoop() { - s.log.Warn("service mode not supported on this platform, falling back to direct mode") - s.acceptLoop() -} - -func (s *Server) platformSessionManager() virtualSessionManager { - return nil -} - -func (s *Server) platformShutdown() { - // no-op on this platform -} diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index 9776667af8d..db8dbce5336 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/server_x11.go b/client/vnc/server/server_x11.go index 6c0b6b643e8..6e6c53fcb70 100644 --- a/client/vnc/server/server_x11.go +++ b/client/vnc/server/server_x11.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || freebsd +//go:build unix && !darwin && !ios && !android package server diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index cd4e89cd2d3..e8d9a5904bf 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/stubs.go b/client/vnc/server/stubs.go index d8441751a91..0417252e0d3 100644 --- a/client/vnc/server/stubs.go +++ b/client/vnc/server/stubs.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/swizzle.go b/client/vnc/server/swizzle.go index 4b34ed63e21..e94a933b6dc 100644 --- a/client/vnc/server/swizzle.go +++ b/client/vnc/server/swizzle.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import "unsafe" diff --git a/client/vnc/server/tight_test.go b/client/vnc/server/tight_test.go index 698f703e8c7..808c1b01a0d 100644 --- a/client/vnc/server/tight_test.go +++ b/client/vnc/server/tight_test.go @@ -1,3 +1,5 @@ +//go:build !js && !ios && !android + package server import ( diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go index 4575a0aabe3..bc2b426c22f 100644 --- a/client/vnc/server/virtual_x11.go +++ b/client/vnc/server/virtual_x11.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || freebsd +//go:build unix && !darwin && !ios && !android package server From b135d462d6894cedfa46b3dd8b9f5bef996db7e9 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 16:33:48 +0200 Subject: [PATCH 021/151] Drop unused zlibState.scratch field --- client/vnc/server/rfb.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 57f9c21bc24..3285c5fe4d2 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -602,14 +602,11 @@ func sampledColorCountInto(seen map[uint32]struct{}, img *image.RGBA, x, y, w, h return len(seen) } -// zlibState holds the persistent zlib writer, output buffer, and a scratch -// slice reused by encodeZlibRect to stage the packed pixel stream before -// handing it to the deflate writer. The scratch grows to the largest rect -// we've seen and is kept for the session lifetime. +// zlibState holds the persistent zlib writer and its output buffer, reused +// across rects so steady-state Tight encoding stays alloc-free. type zlibState struct { - buf *bytes.Buffer - w *zlib.Writer - scratch []byte + buf *bytes.Buffer + w *zlib.Writer } func newZlibState() *zlibState { From a11341f57a2e9afdd16b8b3101de27508cd0c0b0 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 16:34:14 +0200 Subject: [PATCH 022/151] Add ExtendedClipboard pseudo-encoding for UTF-8 bidirectional clipboard --- client/vnc/server/extclipboard.go | 150 +++++++++++++++++++++++++ client/vnc/server/extclipboard_test.go | 96 ++++++++++++++++ client/vnc/server/session.go | 130 ++++++++++++++++++++- 3 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 client/vnc/server/extclipboard.go create mode 100644 client/vnc/server/extclipboard_test.go diff --git a/client/vnc/server/extclipboard.go b/client/vnc/server/extclipboard.go new file mode 100644 index 00000000000..495f0dc6339 --- /dev/null +++ b/client/vnc/server/extclipboard.go @@ -0,0 +1,150 @@ +//go:build !js && !ios && !android + +package server + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "fmt" + "io" +) + +// ExtendedClipboard is an RFB community extension (pseudo-encoding +// 0xC0A1E5CE) that replaces legacy CutText with a Caps/Notify/Request/ +// Provide/Peek handshake. Wins versus legacy CutText: +// - UTF-8 text format (legacy is Latin-1). +// - Pull-based: a Notify announces "I have new content", the peer fetches +// via Request only when it actually needs the data. Saves bandwidth on +// a high-latency relay path versus pushing every change. +// - zlib-compressed payloads. +// - Caps negotiation so each side knows the other's per-format max size. +// +// The extension reuses message opcodes 3 (ServerCutText) and 6 (ClientCutText) +// and signals "extended" by encoding the length field as a negative int32; +// the absolute value is the payload size in bytes. The first 4 bytes of +// payload are a flags word: top byte is the action, low 16 bits are the +// format mask. +const pseudoEncExtendedClipboard = -1063131698 // 0xC0A1E5CE as int32 + +const ( + extClipActionCaps uint32 = 0x01000000 + extClipActionRequest uint32 = 0x02000000 + extClipActionPeek uint32 = 0x04000000 + extClipActionNotify uint32 = 0x08000000 + extClipActionProvide uint32 = 0x10000000 + extClipActionMask uint32 = 0x1F000000 + + extClipFormatText uint32 = 0x00000001 + extClipFormatRTF uint32 = 0x00000002 + extClipFormatHTML uint32 = 0x00000004 + extClipFormatDIB uint32 = 0x00000008 + extClipFormatFiles uint32 = 0x00000010 + extClipFormatMask uint32 = 0x0000FFFF + + // extClipMaxText caps our accepted text payload. Mirrors the legacy + // maxCutTextBytes (1 MiB); advertised in Caps and enforced on Provide. + extClipMaxText = maxCutTextBytes + + // extClipMaxPayload bounds the raw on-wire payload we will read for an + // extended CutText message. Includes flags header, length prefixes, NUL, + // and zlib framing overhead on top of the text body. + extClipMaxPayload = extClipMaxText + 1024 +) + +// buildExtClipCaps emits the Caps payload advertising the formats we accept +// and our maximum size per format. One uint32 size follows the flags word +// for each format bit set, in ascending bit order. +func buildExtClipCaps() []byte { + flags := extClipActionCaps | extClipFormatText + payload := make([]byte, 4+4) + binary.BigEndian.PutUint32(payload[0:4], flags) + binary.BigEndian.PutUint32(payload[4:8], uint32(extClipMaxText)) + return payload +} + +// buildExtClipNotify emits a Notify announcing that we have new clipboard +// content available in the given format mask. No data is shipped; the peer +// pulls via Request when it actually needs to paste. +func buildExtClipNotify(formats uint32) []byte { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, extClipActionNotify|formats) + return payload +} + +// buildExtClipRequest emits a Request asking the peer to send Provide for +// the given format mask. Sent in response to an inbound Notify. +func buildExtClipRequest(formats uint32) []byte { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, extClipActionRequest|formats) + return payload +} + +// buildExtClipProvideText emits a Provide carrying UTF-8 text. The inner +// stream (4-byte length including the trailing NUL, then UTF-8 bytes, then +// NUL) is zlib-compressed; each Provide uses an independent zlib context +// per the extension spec. +func buildExtClipProvideText(text string) ([]byte, error) { + body := make([]byte, 0, 4+len(text)+1) + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(text)+1)) + body = append(body, lenBuf[:]...) + body = append(body, text...) + body = append(body, 0) + + var compressed bytes.Buffer + zw := zlib.NewWriter(&compressed) + if _, err := zw.Write(body); err != nil { + return nil, fmt.Errorf("zlib write: %w", err) + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("zlib close: %w", err) + } + + payload := make([]byte, 4+compressed.Len()) + binary.BigEndian.PutUint32(payload[0:4], extClipActionProvide|extClipFormatText) + copy(payload[4:], compressed.Bytes()) + return payload, nil +} + +// parseExtClipProvideText decompresses a Provide payload (the bytes after +// the 4-byte flags header) and returns the UTF-8 text record if the text +// format bit is set. Records for other formats are skipped. The trailing +// NUL byte the spec appends to text records is stripped. +func parseExtClipProvideText(flags uint32, payload []byte) (string, error) { + zr, err := zlib.NewReader(bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("zlib reader: %w", err) + } + defer zr.Close() + + limited := io.LimitReader(zr, int64(extClipMaxText)+16) + var text string + for bit := uint32(1); bit <= extClipFormatFiles; bit <<= 1 { + if flags&bit == 0 { + continue + } + var sizeBuf [4]byte + if _, err := io.ReadFull(limited, sizeBuf[:]); err != nil { + if bit == extClipFormatText && err == io.EOF { + return "", nil + } + return "", fmt.Errorf("read record size: %w", err) + } + size := binary.BigEndian.Uint32(sizeBuf[:]) + if size > uint32(extClipMaxText) { + return "", fmt.Errorf("record too large: %d", size) + } + rec := make([]byte, size) + if _, err := io.ReadFull(limited, rec); err != nil { + return "", fmt.Errorf("read record: %w", err) + } + if bit == extClipFormatText { + if len(rec) > 0 && rec[len(rec)-1] == 0 { + rec = rec[:len(rec)-1] + } + text = string(rec) + } + } + return text, nil +} diff --git a/client/vnc/server/extclipboard_test.go b/client/vnc/server/extclipboard_test.go new file mode 100644 index 00000000000..fd9601a7901 --- /dev/null +++ b/client/vnc/server/extclipboard_test.go @@ -0,0 +1,96 @@ +//go:build !js && !ios && !android + +package server + +import ( + "encoding/binary" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildExtClipCaps(t *testing.T) { + payload := buildExtClipCaps() + require.Len(t, payload, 8, "Caps with one format should be 4 bytes flags + 4 bytes size") + + flags := binary.BigEndian.Uint32(payload[0:4]) + assert.Equal(t, extClipActionCaps, flags&extClipActionMask, "action should be Caps") + assert.Equal(t, extClipFormatText, flags&extClipFormatMask, "should advertise text format") + + maxSize := binary.BigEndian.Uint32(payload[4:8]) + assert.Equal(t, uint32(extClipMaxText), maxSize, "should advertise extClipMaxText") +} + +func TestBuildExtClipNotify(t *testing.T) { + payload := buildExtClipNotify(extClipFormatText) + require.Len(t, payload, 4) + flags := binary.BigEndian.Uint32(payload) + assert.Equal(t, extClipActionNotify, flags&extClipActionMask) + assert.Equal(t, extClipFormatText, flags&extClipFormatMask) +} + +func TestBuildExtClipRequest(t *testing.T) { + payload := buildExtClipRequest(extClipFormatText) + require.Len(t, payload, 4) + flags := binary.BigEndian.Uint32(payload) + assert.Equal(t, extClipActionRequest, flags&extClipActionMask) + assert.Equal(t, extClipFormatText, flags&extClipFormatMask) +} + +func TestExtClipProvideRoundTripASCII(t *testing.T) { + const original = "hello world" + payload, err := buildExtClipProvideText(original) + require.NoError(t, err) + + flags := binary.BigEndian.Uint32(payload[0:4]) + require.Equal(t, extClipActionProvide, flags&extClipActionMask) + require.Equal(t, extClipFormatText, flags&extClipFormatMask) + + text, err := parseExtClipProvideText(flags, payload[4:]) + require.NoError(t, err) + assert.Equal(t, original, text) +} + +func TestExtClipProvideRoundTripUTF8(t *testing.T) { + original := "héllo 🦀 世界" + payload, err := buildExtClipProvideText(original) + require.NoError(t, err) + + flags := binary.BigEndian.Uint32(payload[0:4]) + text, err := parseExtClipProvideText(flags, payload[4:]) + require.NoError(t, err) + assert.Equal(t, original, text, "UTF-8 should round-trip without mangling") +} + +func TestExtClipProvideRoundTripEmpty(t *testing.T) { + payload, err := buildExtClipProvideText("") + require.NoError(t, err) + + flags := binary.BigEndian.Uint32(payload[0:4]) + text, err := parseExtClipProvideText(flags, payload[4:]) + require.NoError(t, err) + assert.Empty(t, text) +} + +func TestExtClipProvideRoundTripLarge(t *testing.T) { + original := strings.Repeat("abcd", 200000) // 800 KiB, below cap + payload, err := buildExtClipProvideText(original) + require.NoError(t, err) + assert.Less(t, len(payload), len(original)/2, + "highly repetitive text should compress significantly") + + flags := binary.BigEndian.Uint32(payload[0:4]) + text, err := parseExtClipProvideText(flags, payload[4:]) + require.NoError(t, err) + assert.Equal(t, original, text) +} + +func TestParseExtClipProvideTextRejectsOversized(t *testing.T) { + var fakePayload [4]byte + // 4 bytes of zlib-compressed garbage won't decode; we want to ensure we + // don't panic, not that we accept it. + _, err := parseExtClipProvideText(extClipActionProvide|extClipFormatText, fakePayload[:]) + assert.Error(t, err) +} diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index e8d9a5904bf..aa656c28c04 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -60,6 +60,8 @@ type session struct { clientSupportsDesktopName bool clientSupportsLastRect bool clientSupportsQEMUKey bool + clientSupportsExtClipboard bool + extClipCapsSent bool // prevFrame, curFrame and idleFrames live on the encoder goroutine and // must not be touched elsewhere. curFrame holds a session-owned copy of // the capturer's latest frame so the encoder works on a stable buffer @@ -132,12 +134,21 @@ func (s *session) clipboardPoll(done <-chan struct{}) { if len(text) > maxCutTextBytes { text = text[:maxCutTextBytes] } - if text != "" && text != lastClip { - lastClip = text - if err := s.sendServerCutText(text); err != nil { - s.log.Debugf("send clipboard to client: %v", err) + if text == "" || text == lastClip { + continue + } + lastClip = text + s.encMu.RLock() + ext := s.clientSupportsExtClipboard + s.encMu.RUnlock() + if ext { + if err := s.writeExtClipMessage(buildExtClipNotify(extClipFormatText)); err != nil { + s.log.Debugf("send ext clipboard notify: %v", err) return } + } else if err := s.sendServerCutText(text); err != nil { + s.log.Debugf("send clipboard to client: %v", err) + return } } } @@ -319,6 +330,9 @@ func (s *session) handleSetEncodings() error { case pseudoEncQEMUExtendedKeyEvent: s.clientSupportsQEMUKey = true encs = append(encs, "qemu-key") + case pseudoEncExtendedClipboard: + s.clientSupportsExtClipboard = true + encs = append(encs, "ext-clipboard") case encTight: s.useTight = true if s.tight == nil { @@ -327,10 +341,19 @@ func (s *session) handleSetEncodings() error { encs = append(encs, "tight") } } + sendExtClipCaps := s.clientSupportsExtClipboard && !s.extClipCapsSent + if sendExtClipCaps { + s.extClipCapsSent = true + } s.encMu.Unlock() if len(encs) > 0 { s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) } + if sendExtClipCaps { + if err := s.writeExtClipMessage(buildExtClipCaps()); err != nil { + return fmt.Errorf("send ext clipboard caps: %w", err) + } + } return nil } @@ -795,7 +818,16 @@ func (s *session) handleCutText() error { if _, err := io.ReadFull(s.conn, header[:]); err != nil { return fmt.Errorf("read CutText header: %w", err) } - length := binary.BigEndian.Uint32(header[3:7]) + rawLen := int32(binary.BigEndian.Uint32(header[3:7])) + if rawLen < 0 { + // Negative length signals ExtendedClipboard; absolute value is the + // payload size. Guard against MinInt32 overflow before negating. + if rawLen == -2147483648 { + return fmt.Errorf("ext clipboard payload too large") + } + return s.handleExtCutText(uint32(-rawLen)) + } + length := uint32(rawLen) if length > maxCutTextBytes { return fmt.Errorf("cut text too large: %d bytes", length) } @@ -807,6 +839,94 @@ func (s *session) handleCutText() error { return nil } +// handleExtCutText parses an ExtendedClipboard message (any of Caps, +// Notify, Request, Peek, Provide) carried as a negative-length CutText. +// Unknown actions and formats we don't handle (RTF/HTML/DIB/Files) are +// dropped without aborting the session. +func (s *session) handleExtCutText(payloadLen uint32) error { + if payloadLen < 4 { + return fmt.Errorf("ext clipboard payload too short: %d", payloadLen) + } + if payloadLen > extClipMaxPayload { + return fmt.Errorf("ext clipboard payload too large: %d", payloadLen) + } + buf := make([]byte, payloadLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return fmt.Errorf("read ext clipboard payload: %w", err) + } + flags := binary.BigEndian.Uint32(buf[0:4]) + action := flags & extClipActionMask + formats := flags & extClipFormatMask + rest := buf[4:] + + switch action { + case extClipActionCaps: + // Client max sizes are informational for us today: we only emit + // text and already cap it at extClipMaxText. + return nil + case extClipActionRequest: + if formats&extClipFormatText != 0 { + return s.sendExtClipProvideText() + } + return nil + case extClipActionPeek: + return s.writeExtClipMessage(buildExtClipNotify(extClipFormatText)) + case extClipActionNotify: + if formats&extClipFormatText != 0 { + return s.writeExtClipMessage(buildExtClipRequest(extClipFormatText)) + } + return nil + case extClipActionProvide: + if len(rest) == 0 { + return nil + } + text, err := parseExtClipProvideText(flags, rest) + if err != nil { + s.log.Debugf("parse ext clipboard provide: %v", err) + return nil + } + if text != "" { + s.injector.SetClipboard(text) + } + return nil + default: + s.log.Debugf("unknown ext clipboard action 0x%x", action) + return nil + } +} + +// sendExtClipProvideText answers an inbound Request(text) with the current +// host clipboard contents, capped to extClipMaxText. +func (s *session) sendExtClipProvideText() error { + text := s.injector.GetClipboard() + if len(text) > extClipMaxText { + text = text[:extClipMaxText] + } + payload, err := buildExtClipProvideText(text) + if err != nil { + return fmt.Errorf("build provide: %w", err) + } + return s.writeExtClipMessage(payload) +} + +// writeExtClipMessage frames an ExtendedClipboard payload as a ServerCutText +// message with a negative length, then writes it under writeMu. +func (s *session) writeExtClipMessage(payload []byte) error { + if len(payload) == 0 { + return nil + } + buf := make([]byte, 8+len(payload)) + buf[0] = serverCutText + // buf[1:4] = padding (zero) + binary.BigEndian.PutUint32(buf[4:8], uint32(-int32(len(payload)))) + copy(buf[8:], payload) + + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err +} + // handleTypeText handles the NetBird-specific PasteAndType message used by // the dashboard's Paste button. Wire format mirrors CutText: 3-byte // padding + 4-byte length + text bytes. From 76add0b9b2becf39fd99b910c9dc377e7c4784e5 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 16:47:53 +0200 Subject: [PATCH 023/151] Fix ExtendedClipboard auto-request by advertising all actions in Caps --- client/internal/engine_vnc_stub.go | 1 + client/vnc/server/extclipboard.go | 13 +++++++---- client/vnc/server/extclipboard_test.go | 8 ++++++- client/vnc/server/session.go | 32 ++++++++++++++++---------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go index b1d0ac26269..688a588f78d 100644 --- a/client/internal/engine_vnc_stub.go +++ b/client/internal/engine_vnc_stub.go @@ -10,6 +10,7 @@ type vncServer interface{} func (e *Engine) updateVNC(_ *mgmProto.SSHConfig) error { return nil } +// updateVNCServerAuth is a no-op on platforms without a VNC server. func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) {} func (e *Engine) stopVNCServer() error { return nil } diff --git a/client/vnc/server/extclipboard.go b/client/vnc/server/extclipboard.go index 495f0dc6339..171430e776c 100644 --- a/client/vnc/server/extclipboard.go +++ b/client/vnc/server/extclipboard.go @@ -52,11 +52,16 @@ const ( extClipMaxPayload = extClipMaxText + 1024 ) -// buildExtClipCaps emits the Caps payload advertising the formats we accept -// and our maximum size per format. One uint32 size follows the flags word -// for each format bit set, in ascending bit order. +// buildExtClipCaps emits the Caps payload. The flags word advertises every +// action we support in the high byte (Caps + Request + Peek + Notify + +// Provide) and every format we accept in the low 16 bits. noVNC uses these +// action bits to decide whether to auto-Request on Notify; without +// Request in our Caps it silently drops our Notify messages. After the +// flags word we emit one uint32 max size per format bit set, in ascending +// bit order. func buildExtClipCaps() []byte { - flags := extClipActionCaps | extClipFormatText + flags := extClipActionCaps | extClipActionRequest | extClipActionPeek | + extClipActionNotify | extClipActionProvide | extClipFormatText payload := make([]byte, 4+4) binary.BigEndian.PutUint32(payload[0:4], flags) binary.BigEndian.PutUint32(payload[4:8], uint32(extClipMaxText)) diff --git a/client/vnc/server/extclipboard_test.go b/client/vnc/server/extclipboard_test.go index fd9601a7901..43c278bc3c6 100644 --- a/client/vnc/server/extclipboard_test.go +++ b/client/vnc/server/extclipboard_test.go @@ -16,7 +16,13 @@ func TestBuildExtClipCaps(t *testing.T) { require.Len(t, payload, 8, "Caps with one format should be 4 bytes flags + 4 bytes size") flags := binary.BigEndian.Uint32(payload[0:4]) - assert.Equal(t, extClipActionCaps, flags&extClipActionMask, "action should be Caps") + // noVNC checks individual action bits in our Caps to decide whether to + // auto-Request on Notify, so all supported actions must be advertised. + assert.NotZero(t, flags&extClipActionCaps, "Caps action bit must be set") + assert.NotZero(t, flags&extClipActionRequest, "Request action bit must be set") + assert.NotZero(t, flags&extClipActionPeek, "Peek action bit must be set") + assert.NotZero(t, flags&extClipActionNotify, "Notify action bit must be set") + assert.NotZero(t, flags&extClipActionProvide, "Provide action bit must be set") assert.Equal(t, extClipFormatText, flags&extClipFormatMask, "should advertise text format") maxSize := binary.BigEndian.Uint32(payload[4:8]) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index aa656c28c04..e59fb2edd0b 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -877,24 +877,32 @@ func (s *session) handleExtCutText(payloadLen uint32) error { } return nil case extClipActionProvide: - if len(rest) == 0 { - return nil - } - text, err := parseExtClipProvideText(flags, rest) - if err != nil { - s.log.Debugf("parse ext clipboard provide: %v", err) - return nil - } - if text != "" { - s.injector.SetClipboard(text) - } - return nil + return s.handleExtClipProvide(flags, rest) default: s.log.Debugf("unknown ext clipboard action 0x%x", action) return nil } } +// handleExtClipProvide decodes a Provide payload and pushes the recovered +// text into the host clipboard. Errors and other unsupported formats (RTF, +// HTML, etc.) are swallowed so a malformed message doesn't tear down the +// session. +func (s *session) handleExtClipProvide(flags uint32, payload []byte) error { + if len(payload) == 0 { + return nil + } + text, err := parseExtClipProvideText(flags, payload) + if err != nil { + s.log.Debugf("parse ext clipboard provide: %v", err) + return nil + } + if text != "" { + s.injector.SetClipboard(text) + } + return nil +} + // sendExtClipProvideText answers an inbound Request(text) with the current // host clipboard contents, capped to extClipMaxText. func (s *session) sendExtClipProvideText() error { From 61ec8d67de3f647a92a1b7fd4b0a92388dec3f0d Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 16:52:57 +0200 Subject: [PATCH 024/151] Honor QualityLevel and CompressLevel pseudo-encodings --- client/vnc/server/rfb.go | 76 +++++++++++++++++++++++++++++++++--- client/vnc/server/session.go | 40 +++++++++++++------ 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 3285c5fe4d2..e7e2e3b181b 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -69,6 +69,15 @@ const ( pseudoEncDesktopName = -307 pseudoEncExtendedDesktopSize = -308 + // Quality/Compression level pseudo-encodings (TightVNC extension). The + // client picks one value from each range to tune JPEG quality and zlib + // effort. 0 is lowest quality / fastest, 9 is highest quality / best + // compression. + pseudoEncQualityLevelMin = -32 + pseudoEncQualityLevelMax = -23 + pseudoEncCompressLevelMin = -256 + pseudoEncCompressLevelMax = -247 + // Tight compression-control byte top nibble. Stream-reset bits 0-3 // (one per zlib stream) are unused while we run a single stream. tightFillSubenc = 0x80 @@ -427,14 +436,63 @@ type tightState struct { // colorSeen is reused by sampledColorCount per rect; cleared via the Go // runtime's map-clear fast path to avoid a fresh allocation each call. colorSeen map[uint32]struct{} + // jpegQualityOverride forces a fixed JPEG quality on every rect when + // non-zero (set from the client's QualityLevel pseudo-encoding). Zero + // falls back to the area-based tiers in tightQualityFor. + jpegQualityOverride int + // qualityLevel and compressLevel are the 0..9 levels currently applied, + // or -1 if the client did not express a preference. Used to decide + // whether a SetEncodings refresh needs to recreate the tight state. + qualityLevel int + compressLevel int } func newTightState() *tightState { + return newTightStateWithLevels(-1, -1) +} + +// newTightStateWithLevels builds a tightState whose zlib stream and JPEG +// quality reflect the client's QualityLevel / CompressLevel pseudo-encodings. +// Pass -1 for either level to keep our defaults (BestSpeed zlib and the +// area-tiered JPEG quality in tightQualityFor). +func newTightStateWithLevels(qualityLevel, compressLevel int) *tightState { return &tightState{ - jpegBuf: &bytes.Buffer{}, - zlib: newZlibState(), - colorSeen: make(map[uint32]struct{}, 64), + jpegBuf: &bytes.Buffer{}, + zlib: newZlibStateLevel(zlibLevelFor(compressLevel)), + colorSeen: make(map[uint32]struct{}, 64), + jpegQualityOverride: jpegQualityForLevel(qualityLevel), + qualityLevel: qualityLevel, + compressLevel: compressLevel, + } +} + +// jpegQualityForLevel maps a 0..9 client preference to a JPEG quality value. +// Returns 0 when no preference is set (-1), letting the encoder fall back to +// the area-based tiers. +func jpegQualityForLevel(level int) int { + if level < 0 { + return 0 } + if level > 9 { + level = 9 + } + // 0 -> 30, 9 -> 93. Linear so adjacent steps are perceptually similar. + return 30 + level*7 +} + +// zlibLevelFor maps a 0..9 client preference to a zlib compression level. +// Level 0 ("no compression") would emit larger output than input on most +// rects, so we floor to BestSpeed (1). -1 (no preference) also picks +// BestSpeed: matches the historical default before the pseudo-encoding +// was honoured. +func zlibLevelFor(level int) int { + if level < 1 { + return zlib.BestSpeed + } + if level > zlib.BestCompression { + return zlib.BestCompression + } + return level } // encodeTightRect emits a single Tight-encoded rect. Picks Fill for uniform @@ -496,7 +554,11 @@ func encodeTightFill(x, y, w, h int, r, g, b byte) []byte { func encodeTightJPEG(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bool) { t.jpegBuf.Reset() sub := img.SubImage(image.Rect(img.Rect.Min.X+x, img.Rect.Min.Y+y, img.Rect.Min.X+x+w, img.Rect.Min.Y+y+h)) - if err := jpeg.Encode(t.jpegBuf, sub, &jpeg.Options{Quality: tightQualityFor(w * h)}); err != nil { + q := t.jpegQualityOverride + if q == 0 { + q = tightQualityFor(w * h) + } + if err := jpeg.Encode(t.jpegBuf, sub, &jpeg.Options{Quality: q}); err != nil { return nil, false } jpegBytes := t.jpegBuf.Bytes() @@ -610,8 +672,12 @@ type zlibState struct { } func newZlibState() *zlibState { + return newZlibStateLevel(zlib.BestSpeed) +} + +func newZlibStateLevel(level int) *zlibState { buf := &bytes.Buffer{} - w, _ := zlib.NewWriterLevel(buf, zlib.BestSpeed) + w, _ := zlib.NewWriterLevel(buf, level) return &zlibState{buf: buf, w: w} } diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index e59fb2edd0b..be26f2d311d 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -62,6 +62,12 @@ type session struct { clientSupportsQEMUKey bool clientSupportsExtClipboard bool extClipCapsSent bool + // clientJPEGQuality and clientZlibLevel hold the 0..9 levels the client + // advertised via the QualityLevel / CompressLevel pseudo-encodings, or + // -1 when the client has not expressed a preference. Applied to the + // tight encoder state after every SetEncodings. + clientJPEGQuality int + clientZlibLevel int // prevFrame, curFrame and idleFrames live on the encoder goroutine and // must not be touched elsewhere. curFrame holds a session-owned copy of // the capturer's latest frame so the encoder works on a stable buffer @@ -92,6 +98,8 @@ func (s *session) addr() string { return s.conn.RemoteAddr().String() } func (s *session) serve() { defer s.conn.Close() s.pf = defaultClientPixelFormat() + s.clientJPEGQuality = -1 + s.clientZlibLevel = -1 s.encodeCh = make(chan fbRequest, 1) if err := s.handshake(); err != nil { @@ -335,11 +343,21 @@ func (s *session) handleSetEncodings() error { encs = append(encs, "ext-clipboard") case encTight: s.useTight = true - if s.tight == nil { - s.tight = newTightState() - } encs = append(encs, "tight") } + switch { + case enc >= pseudoEncQualityLevelMin && enc <= pseudoEncQualityLevelMax: + s.clientJPEGQuality = int(enc - pseudoEncQualityLevelMin) + encs = append(encs, fmt.Sprintf("quality=%d", s.clientJPEGQuality)) + case enc >= pseudoEncCompressLevelMin && enc <= pseudoEncCompressLevelMax: + s.clientZlibLevel = int(enc - pseudoEncCompressLevelMin) + encs = append(encs, fmt.Sprintf("compress=%d", s.clientZlibLevel)) + } + } + if s.useTight && (s.tight == nil || + s.tight.qualityLevel != s.clientJPEGQuality || + s.tight.compressLevel != s.clientZlibLevel) { + s.tight = newTightStateWithLevels(s.clientJPEGQuality, s.clientZlibLevel) } sendExtClipCaps := s.clientSupportsExtClipboard && !s.extClipCapsSent if sendExtClipCaps { @@ -877,7 +895,8 @@ func (s *session) handleExtCutText(payloadLen uint32) error { } return nil case extClipActionProvide: - return s.handleExtClipProvide(flags, rest) + s.handleExtClipProvide(flags, rest) + return nil default: s.log.Debugf("unknown ext clipboard action 0x%x", action) return nil @@ -885,22 +904,21 @@ func (s *session) handleExtCutText(payloadLen uint32) error { } // handleExtClipProvide decodes a Provide payload and pushes the recovered -// text into the host clipboard. Errors and other unsupported formats (RTF, -// HTML, etc.) are swallowed so a malformed message doesn't tear down the -// session. -func (s *session) handleExtClipProvide(flags uint32, payload []byte) error { +// text into the host clipboard. Decode errors and unsupported formats (RTF, +// HTML, etc.) are logged and dropped so a malformed message doesn't tear +// down the session. +func (s *session) handleExtClipProvide(flags uint32, payload []byte) { if len(payload) == 0 { - return nil + return } text, err := parseExtClipProvideText(flags, payload) if err != nil { s.log.Debugf("parse ext clipboard provide: %v", err) - return nil + return } if text != "" { s.injector.SetClipboard(text) } - return nil } // sendExtClipProvideText answers an inbound Request(text) with the current From 2d0a54f31af56d9bdd258f5c37bab3f4df3c5c42 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 17:16:10 +0200 Subject: [PATCH 025/151] Fix golangci-lint and Sonar: drop newZlibState, extract applyEncoding, inline stub comment --- client/internal/engine_vnc_stub.go | 5 +- client/vnc/server/rfb.go | 4 -- client/vnc/server/session.go | 82 +++++++++++++++++------------- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go index 688a588f78d..505c308db20 100644 --- a/client/internal/engine_vnc_stub.go +++ b/client/internal/engine_vnc_stub.go @@ -10,7 +10,8 @@ type vncServer interface{} func (e *Engine) updateVNC(_ *mgmProto.SSHConfig) error { return nil } -// updateVNCServerAuth is a no-op on platforms without a VNC server. -func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) {} +func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) { + // no-op on platforms without a VNC server +} func (e *Engine) stopVNCServer() error { return nil } diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index e7e2e3b181b..12678e5fe59 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -671,10 +671,6 @@ type zlibState struct { w *zlib.Writer } -func newZlibState() *zlibState { - return newZlibStateLevel(zlib.BestSpeed) -} - func newZlibStateLevel(level int) *zlibState { buf := &bytes.Buffer{} w, _ := zlib.NewWriterLevel(buf, level) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index be26f2d311d..9f9d6e023fe 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -316,42 +316,8 @@ func (s *session) handleSetEncodings() error { s.encMu.Lock() for i := range int(numEnc) { enc := int32(binary.BigEndian.Uint32(buf[i*4 : i*4+4])) - switch enc { - case encCopyRect: - s.useCopyRect = true - if s.copyRectDet == nil { - s.copyRectDet = newCopyRectDetector(tileSize) - } - encs = append(encs, "copyrect") - case pseudoEncDesktopSize: - s.clientSupportsDesktopSize = true - encs = append(encs, "desktop-size") - case pseudoEncExtendedDesktopSize: - s.clientSupportsExtendedDesktopSize = true - encs = append(encs, "ext-desktop-size") - case pseudoEncDesktopName: - s.clientSupportsDesktopName = true - encs = append(encs, "desktop-name") - case pseudoEncLastRect: - s.clientSupportsLastRect = true - encs = append(encs, "last-rect") - case pseudoEncQEMUExtendedKeyEvent: - s.clientSupportsQEMUKey = true - encs = append(encs, "qemu-key") - case pseudoEncExtendedClipboard: - s.clientSupportsExtClipboard = true - encs = append(encs, "ext-clipboard") - case encTight: - s.useTight = true - encs = append(encs, "tight") - } - switch { - case enc >= pseudoEncQualityLevelMin && enc <= pseudoEncQualityLevelMax: - s.clientJPEGQuality = int(enc - pseudoEncQualityLevelMin) - encs = append(encs, fmt.Sprintf("quality=%d", s.clientJPEGQuality)) - case enc >= pseudoEncCompressLevelMin && enc <= pseudoEncCompressLevelMax: - s.clientZlibLevel = int(enc - pseudoEncCompressLevelMin) - encs = append(encs, fmt.Sprintf("compress=%d", s.clientZlibLevel)) + if name := s.applyEncoding(enc); name != "" { + encs = append(encs, name) } } if s.useTight && (s.tight == nil || @@ -375,6 +341,50 @@ func (s *session) handleSetEncodings() error { return nil } +// applyEncoding records a single encoding/pseudo-encoding from a SetEncodings +// message. Returns the short name used in the debug log, or "" if the value +// is one we don't recognise. Caller holds s.encMu. +func (s *session) applyEncoding(enc int32) string { + switch enc { + case encCopyRect: + s.useCopyRect = true + if s.copyRectDet == nil { + s.copyRectDet = newCopyRectDetector(tileSize) + } + return "copyrect" + case pseudoEncDesktopSize: + s.clientSupportsDesktopSize = true + return "desktop-size" + case pseudoEncExtendedDesktopSize: + s.clientSupportsExtendedDesktopSize = true + return "ext-desktop-size" + case pseudoEncDesktopName: + s.clientSupportsDesktopName = true + return "desktop-name" + case pseudoEncLastRect: + s.clientSupportsLastRect = true + return "last-rect" + case pseudoEncQEMUExtendedKeyEvent: + s.clientSupportsQEMUKey = true + return "qemu-key" + case pseudoEncExtendedClipboard: + s.clientSupportsExtClipboard = true + return "ext-clipboard" + case encTight: + s.useTight = true + return "tight" + } + if enc >= pseudoEncQualityLevelMin && enc <= pseudoEncQualityLevelMax { + s.clientJPEGQuality = int(enc - pseudoEncQualityLevelMin) + return fmt.Sprintf("quality=%d", s.clientJPEGQuality) + } + if enc >= pseudoEncCompressLevelMin && enc <= pseudoEncCompressLevelMax { + s.clientZlibLevel = int(enc - pseudoEncCompressLevelMin) + return fmt.Sprintf("compress=%d", s.clientZlibLevel) + } + return "" +} + // handleFBUpdateRequest parses the request and hands it to the encoder // goroutine. It never blocks on capture/encode, so the input dispatch loop // stays responsive even when a previous frame is still being encoded. From 0b8fc5da59a2389ff3ce91e2e0e311f02c03c987 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 17:32:01 +0200 Subject: [PATCH 026/151] Split session.go: encoder pipeline and clipboard handling into separate files --- client/vnc/server/session.go | 571 ------------------------- client/vnc/server/session_clipboard.go | 203 +++++++++ client/vnc/server/session_encode.go | 395 +++++++++++++++++ 3 files changed, 598 insertions(+), 571 deletions(-) create mode 100644 client/vnc/server/session_clipboard.go create mode 100644 client/vnc/server/session_encode.go diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 9f9d6e023fe..1c250c4fc82 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -4,7 +4,6 @@ package server import ( "encoding/binary" - "errors" "fmt" "image" "io" @@ -126,42 +125,6 @@ func (s *session) serve() { } } -// clipboardPoll periodically checks the server-side clipboard and sends -// changes to the VNC client. Only runs during active sessions. -func (s *session) clipboardPoll(done <-chan struct{}) { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - var lastClip string - for { - select { - case <-done: - return - case <-ticker.C: - text := s.injector.GetClipboard() - if len(text) > maxCutTextBytes { - text = text[:maxCutTextBytes] - } - if text == "" || text == lastClip { - continue - } - lastClip = text - s.encMu.RLock() - ext := s.clientSupportsExtClipboard - s.encMu.RUnlock() - if ext { - if err := s.writeExtClipMessage(buildExtClipNotify(extClipFormatText)); err != nil { - s.log.Debugf("send ext clipboard notify: %v", err) - return - } - } else if err := s.sendServerCutText(text); err != nil { - s.log.Debugf("send clipboard to client: %v", err) - return - } - } - } -} - func (s *session) handshake() error { // Send protocol version. if _, err := io.WriteString(s.conn, rfbProtocolVersion); err != nil { @@ -411,192 +374,6 @@ func (s *session) handleFBUpdateRequest() error { return nil } -// encoderLoop owns the capture → diff → encode → write pipeline. Running it -// off the read loop prevents a slow encode (zlib full-frame, many dirty -// tiles) from blocking inbound input events. -func (s *session) encoderLoop(done chan<- struct{}) { - defer close(done) - for req := range s.encodeCh { - if err := s.processFBRequest(req); err != nil { - s.log.Debugf("encode: %v", err) - // On write/capture error, close the connection so messageLoop - // exits and the session terminates cleanly. - s.conn.Close() - drainRequests(s.encodeCh) - return - } - } -} - -func (s *session) processFBRequest(req fbRequest) error { - // Watch for resolution changes between cycles. When the capturer - // reports a new size, tell the client via DesktopSize so it can - // reallocate its backing buffer; the next full update will then fill - // the new dimensions. Clients that didn't advertise support are stuck - // with the original handshake size and just see clipping on resize. - if err := s.handleResize(); err != nil { - return err - } - - img, err := s.captureFrame() - if errors.Is(err, errFrameUnchanged) { - // macOS hashes the raw capture bytes and short-circuits when the - // screen is byte-identical. Treat as "no dirty rects" to skip the - // diff and send an empty update. - s.idleFrames++ - delay := min(s.idleFrames*5, 100) - time.Sleep(time.Duration(delay) * time.Millisecond) - return s.sendEmptyUpdate() - } - if err != nil { - // Capture failures are transient on Windows: a Ctrl+Alt+Del or - // sign-out switches the OS to the secure desktop, and the DXGI - // duplicator on the previous desktop returns an error until the - // capturer reattaches on the new desktop. On Linux the X server - // behind a virtual session may exit and the capturer reports - // "unavailable" on every retry tick. Don't tear down the session - // and don't spam the log: emit one line on the first failure, then - // throttle further "still failing" lines to once per 5 s. - s.captureErrorLog(err) - time.Sleep(100 * time.Millisecond) - return s.sendEmptyUpdate() - } - s.captureRecovered() - - if req.incremental && s.prevFrame != nil { - tiles := diffTiles(s.prevFrame, img, s.serverW, s.serverH, tileSize) - if len(tiles) == 0 { - // Nothing changed. Back off briefly before responding to reduce - // CPU usage when the screen is static. The client re-requests - // immediately after receiving our empty response, so without - // this delay we'd spin at ~1000fps checking for changes. - s.idleFrames++ - delay := min(s.idleFrames*5, 100) // 5ms → 100ms adaptive backoff - time.Sleep(time.Duration(delay) * time.Millisecond) - s.swapPrevCur() - return s.sendEmptyUpdate() - } - s.idleFrames = 0 - - // Snapshot the dirty set before extractCopyRectTiles consumes it. - // extract mutates in place, so without the copy we lose the - // move-destination positions needed to incrementally update the - // CopyRect index after the swap. - dirty := make([][4]int, len(tiles)) - copy(dirty, tiles) - - var moves []copyRectMove - if s.useCopyRect && s.copyRectDet != nil { - moves, tiles = s.copyRectDet.extractCopyRectTiles(img, tiles) - } - - rects := coalesceRects(tiles) - if s.shouldPromoteToFullFrame(rects) && len(moves) == 0 { - if err := s.sendFullUpdate(img); err != nil { - return err - } - s.swapPrevCur() - s.refreshCopyRectIndex() - return nil - } - if err := s.sendDirtyAndMoves(img, moves, rects); err != nil { - return err - } - s.swapPrevCur() - s.updateCopyRectIndex(dirty) - return nil - } - - // Full update. - s.idleFrames = 0 - if err := s.sendFullUpdate(img); err != nil { - return err - } - s.swapPrevCur() - s.refreshCopyRectIndex() - return nil -} - -// captureErrorLog emits one log line on the first failure after success, -// then at most once every captureErrThrottle while the capturer keeps -// failing. The "recovered" transition is logged once when err is nil and -// captureErrSeen was set. -func (s *session) captureErrorLog(err error) { - const captureErrThrottle = 5 * time.Second - now := time.Now() - if !s.captureErrSeen || now.Sub(s.captureErrLast) >= captureErrThrottle { - s.log.Debugf("capture (transient): %v", err) - s.captureErrLast = now - } - s.captureErrSeen = true -} - -// captureRecovered emits a one-shot debug line when capture works again -// after a failure streak. Called by the success paths. -func (s *session) captureRecovered() { - if s.captureErrSeen { - s.log.Debugf("capture recovered") - s.captureErrSeen = false - } -} - -// handleResize detects framebuffer-size changes between encode cycles and -// notifies the client via the DesktopSize pseudo-encoding. Returns an -// error only on write failure; capturers that don't expose Width/Height -// yet (zero values during early startup) are silently ignored. -func (s *session) handleResize() error { - w, h := s.capturer.Width(), s.capturer.Height() - if w <= 0 || h <= 0 { - return nil - } - if w == s.serverW && h == s.serverH { - return nil - } - s.log.Debugf("framebuffer resized: %dx%d -> %dx%d", s.serverW, s.serverH, w, h) - s.serverW = w - s.serverH = h - // Drop the prev frame so the next encode produces a full update at - // the new dimensions rather than diffing against a stale-sized buffer. - s.prevFrame = nil - s.curFrame = nil - if s.copyRectDet != nil { - // Tile geometry changed; let updateDirty rebuild from scratch on - // the next pass instead of reusing stale hashes keyed on old - // (cols, rows). - s.copyRectDet.prevTiles = nil - s.copyRectDet.tileHash = nil - } - if err := s.sendDesktopSize(w, h); err != nil { - return fmt.Errorf("send desktop size: %w", err) - } - return nil -} - -// sendDesktopSize emits a single-rect FramebufferUpdate carrying the -// DesktopSize pseudo-encoding. No-op if the client did not negotiate it, -// in which case the client just sees the new dimensions on the next full -// update and will likely clip or scale. -func (s *session) sendDesktopSize(w, h int) error { - s.encMu.RLock() - supported := s.clientSupportsDesktopSize || s.clientSupportsExtendedDesktopSize - s.encMu.RUnlock() - if !supported { - return nil - } - header := make([]byte, 4) - header[0] = serverFramebufferUpdate - binary.BigEndian.PutUint16(header[2:4], 1) - - body := encodeDesktopSizeBody(w, h) - s.writeMu.Lock() - defer s.writeMu.Unlock() - if _, err := s.conn.Write(header); err != nil { - return err - } - _, err := s.conn.Write(body) - return err -} - // SendDesktopName pushes a DesktopName pseudo-encoded update to the // client if it advertised support. Used by the server to keep the // dashboard title in sync with the active session (e.g. username @@ -624,179 +401,6 @@ func (s *session) SendDesktopName(name string) error { return err } -// refreshCopyRectIndex does a full hash sweep of the just-swapped prevFrame. -// Used after full-frame sends, where we don't have a per-tile dirty list to -// drive an incremental update. -func (s *session) refreshCopyRectIndex() { - if s.copyRectDet == nil || s.prevFrame == nil { - return - } - s.copyRectDet.rebuild(s.prevFrame, s.serverW, s.serverH) -} - -// updateCopyRectIndex incrementally updates the CopyRect detector's hash -// tables for the tiles that just changed. On first use (or after resize) -// updateDirty internally falls back to a full rebuild. -func (s *session) updateCopyRectIndex(dirty [][4]int) { - if s.copyRectDet == nil || s.prevFrame == nil { - return - } - s.copyRectDet.updateDirty(s.prevFrame, s.serverW, s.serverH, dirty) -} - -// captureFrame returns a session-owned frame for this encode cycle. -// Capturers that implement captureIntoer (Linux X11, macOS) write directly -// into curFrame, saving a per-frame full-screen memcpy. Capturers that -// don't (Windows DXGI) return their own buffer which we copy into curFrame -// to keep the encoder's prevFrame stable across the next capture cycle. -func (s *session) captureFrame() (*image.RGBA, error) { - w, h := s.serverW, s.serverH - if s.curFrame == nil || s.curFrame.Rect.Dx() != w || s.curFrame.Rect.Dy() != h { - s.curFrame = image.NewRGBA(image.Rect(0, 0, w, h)) - } - - if ci, ok := s.capturer.(captureIntoer); ok { - if err := ci.CaptureInto(s.curFrame); err != nil { - return nil, err - } - return s.curFrame, nil - } - - src, err := s.capturer.Capture() - if err != nil { - return nil, err - } - if s.curFrame.Rect != src.Rect { - s.curFrame = image.NewRGBA(src.Rect) - } - copy(s.curFrame.Pix, src.Pix) - return s.curFrame, nil -} - -// shouldPromoteToFullFrame returns true when the dirty rect set covers a -// large enough fraction of the screen that a single full-frame zlib rect -// beats per-tile encoding on both CPU time and wire bytes. The crossover -// is measured via BenchmarkEncodeManyTilesVsFullFrame. -func (s *session) shouldPromoteToFullFrame(rects [][4]int) bool { - if s.serverW == 0 || s.serverH == 0 { - return false - } - var dirty int - for _, r := range rects { - dirty += r[2] * r[3] - } - return dirty*fullFramePromoteDen > s.serverW*s.serverH*fullFramePromoteNum -} - -// swapPrevCur makes the just-encoded frame the new prevFrame (for the next -// diff) and lets the old prevFrame buffer become the next curFrame. Avoids -// an 8 MB copy per frame compared to the old savePrevFrame path. -func (s *session) swapPrevCur() { - s.prevFrame, s.curFrame = s.curFrame, s.prevFrame -} - -// sendEmptyUpdate sends a FramebufferUpdate with zero rectangles. -func (s *session) sendEmptyUpdate() error { - var buf [4]byte - buf[0] = serverFramebufferUpdate - s.writeMu.Lock() - _, err := s.conn.Write(buf[:]) - s.writeMu.Unlock() - return err -} - -func (s *session) sendFullUpdate(img *image.RGBA) error { - w, h := s.serverW, s.serverH - - s.encMu.RLock() - pf := s.pf - useTight := s.useTight - tight := s.tight - s.encMu.RUnlock() - - if useTight && tight != nil && pfIsTightCompatible(pf) { - // Tight encodes arbitrary sizes natively (Fill for uniform, JPEG - // for photo-like, Basic+zlib otherwise). Wrap the rect bytes with - // the 4-byte FramebufferUpdate header. - rectBuf := encodeTightRect(img, pf, 0, 0, w, h, tight) - buf := make([]byte, 4+len(rectBuf)) - buf[0] = serverFramebufferUpdate - binary.BigEndian.PutUint16(buf[2:4], 1) - copy(buf[4:], rectBuf) - s.writeMu.Lock() - _, err := s.conn.Write(buf) - s.writeMu.Unlock() - return err - } - - buf := encodeRawRect(img, pf, 0, 0, w, h) - s.writeMu.Lock() - _, err := s.conn.Write(buf) - s.writeMu.Unlock() - return err -} - -// sendDirtyAndMoves writes one FramebufferUpdate combining CopyRect moves -// (cheap, 16 bytes each) and pixel-encoded dirty rects. Moves come first so -// their source tiles are read from the client's pre-update framebuffer state, -// before any subsequent rect overwrites them. -func (s *session) sendDirtyAndMoves(img *image.RGBA, moves []copyRectMove, rects [][4]int) error { - if len(moves) == 0 && len(rects) == 0 { - return nil - } - - total := len(moves) + len(rects) - header := make([]byte, 4) - header[0] = serverFramebufferUpdate - binary.BigEndian.PutUint16(header[2:4], uint16(total)) - - s.writeMu.Lock() - defer s.writeMu.Unlock() - - if _, err := s.conn.Write(header); err != nil { - return err - } - - ts := tileSize - for _, m := range moves { - body := encodeCopyRectBody(m.srcX, m.srcY, m.dstX, m.dstY, ts, ts) - if _, err := s.conn.Write(body); err != nil { - return err - } - } - - for _, r := range rects { - x, y, w, h := r[0], r[1], r[2], r[3] - rectBuf := s.encodeTile(img, x, y, w, h) - if _, err := s.conn.Write(rectBuf); err != nil { - return err - } - } - return nil -} - -// encodeTile produces the on-wire rect bytes for a single dirty tile. Tight -// is the only non-Raw encoding we negotiate: uniform tiles collapse to its -// Fill subencoding (~16 bytes), photo-like rects route to JPEG, and the -// rest take the Basic+zlib path. Raw is the fallback when Tight is not -// negotiated or the negotiated pixel format is incompatible with Tight's -// mandatory 24-bit RGB TPIXEL encoding. -// -// Output omits the 4-byte FramebufferUpdate header; callers combine multiple -// tiles into one message. -func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { - s.encMu.RLock() - pf := s.pf - useTight := s.useTight - tight := s.tight - s.encMu.RUnlock() - - if useTight && tight != nil && pfIsTightCompatible(pf) { - return encodeTightRect(img, pf, x, y, w, h, tight) - } - return encodeRawRect(img, pf, x, y, w, h)[4:] -} - func (s *session) handleKeyEvent() error { var data [7]byte if _, err := io.ReadFull(s.conn, data[:]); err != nil { @@ -840,178 +444,3 @@ func (s *session) handlePointerEvent() error { s.injector.InjectPointer(buttonMask, x, y, s.serverW, s.serverH) return nil } - -func (s *session) handleCutText() error { - var header [7]byte // 3 padding + 4 length - if _, err := io.ReadFull(s.conn, header[:]); err != nil { - return fmt.Errorf("read CutText header: %w", err) - } - rawLen := int32(binary.BigEndian.Uint32(header[3:7])) - if rawLen < 0 { - // Negative length signals ExtendedClipboard; absolute value is the - // payload size. Guard against MinInt32 overflow before negating. - if rawLen == -2147483648 { - return fmt.Errorf("ext clipboard payload too large") - } - return s.handleExtCutText(uint32(-rawLen)) - } - length := uint32(rawLen) - if length > maxCutTextBytes { - return fmt.Errorf("cut text too large: %d bytes", length) - } - buf := make([]byte, length) - if _, err := io.ReadFull(s.conn, buf); err != nil { - return fmt.Errorf("read CutText payload: %w", err) - } - s.injector.SetClipboard(string(buf)) - return nil -} - -// handleExtCutText parses an ExtendedClipboard message (any of Caps, -// Notify, Request, Peek, Provide) carried as a negative-length CutText. -// Unknown actions and formats we don't handle (RTF/HTML/DIB/Files) are -// dropped without aborting the session. -func (s *session) handleExtCutText(payloadLen uint32) error { - if payloadLen < 4 { - return fmt.Errorf("ext clipboard payload too short: %d", payloadLen) - } - if payloadLen > extClipMaxPayload { - return fmt.Errorf("ext clipboard payload too large: %d", payloadLen) - } - buf := make([]byte, payloadLen) - if _, err := io.ReadFull(s.conn, buf); err != nil { - return fmt.Errorf("read ext clipboard payload: %w", err) - } - flags := binary.BigEndian.Uint32(buf[0:4]) - action := flags & extClipActionMask - formats := flags & extClipFormatMask - rest := buf[4:] - - switch action { - case extClipActionCaps: - // Client max sizes are informational for us today: we only emit - // text and already cap it at extClipMaxText. - return nil - case extClipActionRequest: - if formats&extClipFormatText != 0 { - return s.sendExtClipProvideText() - } - return nil - case extClipActionPeek: - return s.writeExtClipMessage(buildExtClipNotify(extClipFormatText)) - case extClipActionNotify: - if formats&extClipFormatText != 0 { - return s.writeExtClipMessage(buildExtClipRequest(extClipFormatText)) - } - return nil - case extClipActionProvide: - s.handleExtClipProvide(flags, rest) - return nil - default: - s.log.Debugf("unknown ext clipboard action 0x%x", action) - return nil - } -} - -// handleExtClipProvide decodes a Provide payload and pushes the recovered -// text into the host clipboard. Decode errors and unsupported formats (RTF, -// HTML, etc.) are logged and dropped so a malformed message doesn't tear -// down the session. -func (s *session) handleExtClipProvide(flags uint32, payload []byte) { - if len(payload) == 0 { - return - } - text, err := parseExtClipProvideText(flags, payload) - if err != nil { - s.log.Debugf("parse ext clipboard provide: %v", err) - return - } - if text != "" { - s.injector.SetClipboard(text) - } -} - -// sendExtClipProvideText answers an inbound Request(text) with the current -// host clipboard contents, capped to extClipMaxText. -func (s *session) sendExtClipProvideText() error { - text := s.injector.GetClipboard() - if len(text) > extClipMaxText { - text = text[:extClipMaxText] - } - payload, err := buildExtClipProvideText(text) - if err != nil { - return fmt.Errorf("build provide: %w", err) - } - return s.writeExtClipMessage(payload) -} - -// writeExtClipMessage frames an ExtendedClipboard payload as a ServerCutText -// message with a negative length, then writes it under writeMu. -func (s *session) writeExtClipMessage(payload []byte) error { - if len(payload) == 0 { - return nil - } - buf := make([]byte, 8+len(payload)) - buf[0] = serverCutText - // buf[1:4] = padding (zero) - binary.BigEndian.PutUint32(buf[4:8], uint32(-int32(len(payload)))) - copy(buf[8:], payload) - - s.writeMu.Lock() - _, err := s.conn.Write(buf) - s.writeMu.Unlock() - return err -} - -// handleTypeText handles the NetBird-specific PasteAndType message used by -// the dashboard's Paste button. Wire format mirrors CutText: 3-byte -// padding + 4-byte length + text bytes. -func (s *session) handleTypeText() error { - var header [7]byte - if _, err := io.ReadFull(s.conn, header[:]); err != nil { - return fmt.Errorf("read TypeText header: %w", err) - } - length := binary.BigEndian.Uint32(header[3:7]) - if length > maxCutTextBytes { - return fmt.Errorf("type text too large: %d bytes", length) - } - buf := make([]byte, length) - if _, err := io.ReadFull(s.conn, buf); err != nil { - return fmt.Errorf("read TypeText payload: %w", err) - } - s.injector.TypeText(string(buf)) - return nil -} - -// sendServerCutText sends clipboard text from the server to the client. -func (s *session) sendServerCutText(text string) error { - data := []byte(text) - buf := make([]byte, 8+len(data)) - buf[0] = serverCutText - // buf[1:4] = padding (zero) - binary.BigEndian.PutUint32(buf[4:8], uint32(len(data))) - copy(buf[8:], data) - - s.writeMu.Lock() - _, err := s.conn.Write(buf) - s.writeMu.Unlock() - return err -} - -// drainRequests consumes any pending requests so the sender's close completes -// cleanly after the encoder loop has decided to exit on error. Returns the -// number of drained requests to defeat empty-block lints; callers ignore it. -func drainRequests(ch chan fbRequest) int { - var drained int - for range ch { - drained++ - } - return drained -} - -// pfIsTightCompatible reports whether the negotiated client pixel format -// matches Tight's TPIXEL constraint: standard RGB shifts (R=16, G=8, B=0). -// bpp/endianness/channel-max are already locked at SetPixelFormat time. -func pfIsTightCompatible(pf clientPixelFormat) bool { - return pf.rShift == 16 && pf.gShift == 8 && pf.bShift == 0 -} diff --git a/client/vnc/server/session_clipboard.go b/client/vnc/server/session_clipboard.go new file mode 100644 index 00000000000..4d73421987f --- /dev/null +++ b/client/vnc/server/session_clipboard.go @@ -0,0 +1,203 @@ +//go:build !js && !ios && !android + +package server + +import ( + "encoding/binary" + "fmt" + "io" + "time" +) + +// clipboardPoll periodically checks the server-side clipboard and sends +// changes to the VNC client. Only runs during active sessions. +func (s *session) clipboardPoll(done <-chan struct{}) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + var lastClip string + for { + select { + case <-done: + return + case <-ticker.C: + text := s.injector.GetClipboard() + if len(text) > maxCutTextBytes { + text = text[:maxCutTextBytes] + } + if text == "" || text == lastClip { + continue + } + lastClip = text + s.encMu.RLock() + ext := s.clientSupportsExtClipboard + s.encMu.RUnlock() + if ext { + if err := s.writeExtClipMessage(buildExtClipNotify(extClipFormatText)); err != nil { + s.log.Debugf("send ext clipboard notify: %v", err) + return + } + } else if err := s.sendServerCutText(text); err != nil { + s.log.Debugf("send clipboard to client: %v", err) + return + } + } + } +} + +func (s *session) handleCutText() error { + var header [7]byte // 3 padding + 4 length + if _, err := io.ReadFull(s.conn, header[:]); err != nil { + return fmt.Errorf("read CutText header: %w", err) + } + rawLen := int32(binary.BigEndian.Uint32(header[3:7])) + if rawLen < 0 { + // Negative length signals ExtendedClipboard; absolute value is the + // payload size. Guard against MinInt32 overflow before negating. + if rawLen == -2147483648 { + return fmt.Errorf("ext clipboard payload too large") + } + return s.handleExtCutText(uint32(-rawLen)) + } + length := uint32(rawLen) + if length > maxCutTextBytes { + return fmt.Errorf("cut text too large: %d bytes", length) + } + buf := make([]byte, length) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return fmt.Errorf("read CutText payload: %w", err) + } + s.injector.SetClipboard(string(buf)) + return nil +} + +// handleExtCutText parses an ExtendedClipboard message (any of Caps, +// Notify, Request, Peek, Provide) carried as a negative-length CutText. +// Unknown actions and formats we don't handle (RTF/HTML/DIB/Files) are +// dropped without aborting the session. +func (s *session) handleExtCutText(payloadLen uint32) error { + if payloadLen < 4 { + return fmt.Errorf("ext clipboard payload too short: %d", payloadLen) + } + if payloadLen > extClipMaxPayload { + return fmt.Errorf("ext clipboard payload too large: %d", payloadLen) + } + buf := make([]byte, payloadLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return fmt.Errorf("read ext clipboard payload: %w", err) + } + flags := binary.BigEndian.Uint32(buf[0:4]) + action := flags & extClipActionMask + formats := flags & extClipFormatMask + rest := buf[4:] + + switch action { + case extClipActionCaps: + // Client max sizes are informational for us today: we only emit + // text and already cap it at extClipMaxText. + return nil + case extClipActionRequest: + if formats&extClipFormatText != 0 { + return s.sendExtClipProvideText() + } + return nil + case extClipActionPeek: + return s.writeExtClipMessage(buildExtClipNotify(extClipFormatText)) + case extClipActionNotify: + if formats&extClipFormatText != 0 { + return s.writeExtClipMessage(buildExtClipRequest(extClipFormatText)) + } + return nil + case extClipActionProvide: + s.handleExtClipProvide(flags, rest) + return nil + default: + s.log.Debugf("unknown ext clipboard action 0x%x", action) + return nil + } +} + +// handleExtClipProvide decodes a Provide payload and pushes the recovered +// text into the host clipboard. Decode errors and unsupported formats (RTF, +// HTML, etc.) are logged and dropped so a malformed message doesn't tear +// down the session. +func (s *session) handleExtClipProvide(flags uint32, payload []byte) { + if len(payload) == 0 { + return + } + text, err := parseExtClipProvideText(flags, payload) + if err != nil { + s.log.Debugf("parse ext clipboard provide: %v", err) + return + } + if text != "" { + s.injector.SetClipboard(text) + } +} + +// sendExtClipProvideText answers an inbound Request(text) with the current +// host clipboard contents, capped to extClipMaxText. +func (s *session) sendExtClipProvideText() error { + text := s.injector.GetClipboard() + if len(text) > extClipMaxText { + text = text[:extClipMaxText] + } + payload, err := buildExtClipProvideText(text) + if err != nil { + return fmt.Errorf("build provide: %w", err) + } + return s.writeExtClipMessage(payload) +} + +// writeExtClipMessage frames an ExtendedClipboard payload as a ServerCutText +// message with a negative length, then writes it under writeMu. +func (s *session) writeExtClipMessage(payload []byte) error { + if len(payload) == 0 { + return nil + } + buf := make([]byte, 8+len(payload)) + buf[0] = serverCutText + // buf[1:4] = padding (zero) + binary.BigEndian.PutUint32(buf[4:8], uint32(-int32(len(payload)))) + copy(buf[8:], payload) + + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err +} + +// handleTypeText handles the NetBird-specific PasteAndType message used by +// the dashboard's Paste button. Wire format mirrors CutText: 3-byte +// padding + 4-byte length + text bytes. +func (s *session) handleTypeText() error { + var header [7]byte + if _, err := io.ReadFull(s.conn, header[:]); err != nil { + return fmt.Errorf("read TypeText header: %w", err) + } + length := binary.BigEndian.Uint32(header[3:7]) + if length > maxCutTextBytes { + return fmt.Errorf("type text too large: %d bytes", length) + } + buf := make([]byte, length) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return fmt.Errorf("read TypeText payload: %w", err) + } + s.injector.TypeText(string(buf)) + return nil +} + +// sendServerCutText sends clipboard text from the server to the client. +func (s *session) sendServerCutText(text string) error { + data := []byte(text) + buf := make([]byte, 8+len(data)) + buf[0] = serverCutText + // buf[1:4] = padding (zero) + binary.BigEndian.PutUint32(buf[4:8], uint32(len(data))) + copy(buf[8:], data) + + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err +} diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go new file mode 100644 index 00000000000..7605eff2d7c --- /dev/null +++ b/client/vnc/server/session_encode.go @@ -0,0 +1,395 @@ +//go:build !js && !ios && !android + +package server + +import ( + "encoding/binary" + "errors" + "fmt" + "image" + "time" +) + +// encoderLoop owns the capture → diff → encode → write pipeline. Running it +// off the read loop prevents a slow encode (zlib full-frame, many dirty +// tiles) from blocking inbound input events. +func (s *session) encoderLoop(done chan<- struct{}) { + defer close(done) + for req := range s.encodeCh { + if err := s.processFBRequest(req); err != nil { + s.log.Debugf("encode: %v", err) + // On write/capture error, close the connection so messageLoop + // exits and the session terminates cleanly. + s.conn.Close() + drainRequests(s.encodeCh) + return + } + } +} + +func (s *session) processFBRequest(req fbRequest) error { + // Watch for resolution changes between cycles. When the capturer + // reports a new size, tell the client via DesktopSize so it can + // reallocate its backing buffer; the next full update will then fill + // the new dimensions. Clients that didn't advertise support are stuck + // with the original handshake size and just see clipping on resize. + if err := s.handleResize(); err != nil { + return err + } + + img, err := s.captureFrame() + if errors.Is(err, errFrameUnchanged) { + // macOS hashes the raw capture bytes and short-circuits when the + // screen is byte-identical. Treat as "no dirty rects" to skip the + // diff and send an empty update. + s.idleFrames++ + delay := min(s.idleFrames*5, 100) + time.Sleep(time.Duration(delay) * time.Millisecond) + return s.sendEmptyUpdate() + } + if err != nil { + // Capture failures are transient on Windows: a Ctrl+Alt+Del or + // sign-out switches the OS to the secure desktop, and the DXGI + // duplicator on the previous desktop returns an error until the + // capturer reattaches on the new desktop. On Linux the X server + // behind a virtual session may exit and the capturer reports + // "unavailable" on every retry tick. Don't tear down the session + // and don't spam the log: emit one line on the first failure, then + // throttle further "still failing" lines to once per 5 s. + s.captureErrorLog(err) + time.Sleep(100 * time.Millisecond) + return s.sendEmptyUpdate() + } + s.captureRecovered() + + if req.incremental && s.prevFrame != nil { + return s.processIncremental(img) + } + + // Full update. + s.idleFrames = 0 + if err := s.sendFullUpdate(img); err != nil { + return err + } + s.swapPrevCur() + s.refreshCopyRectIndex() + return nil +} + +// processIncremental handles the diff/encode path for a non-initial frame. +// Returns nil after writing either an empty update (no changes) or a mix of +// CopyRect moves and pixel-encoded dirty rects. +func (s *session) processIncremental(img *image.RGBA) error { + tiles := diffTiles(s.prevFrame, img, s.serverW, s.serverH, tileSize) + if len(tiles) == 0 { + // Nothing changed. Back off briefly before responding to reduce + // CPU usage when the screen is static. The client re-requests + // immediately after receiving our empty response, so without + // this delay we'd spin at ~1000fps checking for changes. + s.idleFrames++ + delay := min(s.idleFrames*5, 100) // 5ms → 100ms adaptive backoff + time.Sleep(time.Duration(delay) * time.Millisecond) + s.swapPrevCur() + return s.sendEmptyUpdate() + } + s.idleFrames = 0 + + // Snapshot the dirty set before extractCopyRectTiles consumes it. + // extract mutates in place, so without the copy we lose the + // move-destination positions needed to incrementally update the + // CopyRect index after the swap. + dirty := make([][4]int, len(tiles)) + copy(dirty, tiles) + + var moves []copyRectMove + if s.useCopyRect && s.copyRectDet != nil { + moves, tiles = s.copyRectDet.extractCopyRectTiles(img, tiles) + } + + rects := coalesceRects(tiles) + if s.shouldPromoteToFullFrame(rects) && len(moves) == 0 { + if err := s.sendFullUpdate(img); err != nil { + return err + } + s.swapPrevCur() + s.refreshCopyRectIndex() + return nil + } + if err := s.sendDirtyAndMoves(img, moves, rects); err != nil { + return err + } + s.swapPrevCur() + s.updateCopyRectIndex(dirty) + return nil +} + +// captureErrorLog emits one log line on the first failure after success, +// then at most once every captureErrThrottle while the capturer keeps +// failing. The "recovered" transition is logged once when err is nil and +// captureErrSeen was set. +func (s *session) captureErrorLog(err error) { + const captureErrThrottle = 5 * time.Second + now := time.Now() + if !s.captureErrSeen || now.Sub(s.captureErrLast) >= captureErrThrottle { + s.log.Debugf("capture (transient): %v", err) + s.captureErrLast = now + } + s.captureErrSeen = true +} + +// captureRecovered emits a one-shot debug line when capture works again +// after a failure streak. Called by the success paths. +func (s *session) captureRecovered() { + if s.captureErrSeen { + s.log.Debugf("capture recovered") + s.captureErrSeen = false + } +} + +// handleResize detects framebuffer-size changes between encode cycles and +// notifies the client via the DesktopSize pseudo-encoding. Returns an +// error only on write failure; capturers that don't expose Width/Height +// yet (zero values during early startup) are silently ignored. +func (s *session) handleResize() error { + w, h := s.capturer.Width(), s.capturer.Height() + if w <= 0 || h <= 0 { + return nil + } + if w == s.serverW && h == s.serverH { + return nil + } + s.log.Debugf("framebuffer resized: %dx%d -> %dx%d", s.serverW, s.serverH, w, h) + s.serverW = w + s.serverH = h + // Drop the prev frame so the next encode produces a full update at + // the new dimensions rather than diffing against a stale-sized buffer. + s.prevFrame = nil + s.curFrame = nil + if s.copyRectDet != nil { + // Tile geometry changed; let updateDirty rebuild from scratch on + // the next pass instead of reusing stale hashes keyed on old + // (cols, rows). + s.copyRectDet.prevTiles = nil + s.copyRectDet.tileHash = nil + } + if err := s.sendDesktopSize(w, h); err != nil { + return fmt.Errorf("send desktop size: %w", err) + } + return nil +} + +// sendDesktopSize emits a single-rect FramebufferUpdate carrying the +// DesktopSize pseudo-encoding. No-op if the client did not negotiate it, +// in which case the client just sees the new dimensions on the next full +// update and will likely clip or scale. +func (s *session) sendDesktopSize(w, h int) error { + s.encMu.RLock() + supported := s.clientSupportsDesktopSize || s.clientSupportsExtendedDesktopSize + s.encMu.RUnlock() + if !supported { + return nil + } + header := make([]byte, 4) + header[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(header[2:4], 1) + + body := encodeDesktopSizeBody(w, h) + s.writeMu.Lock() + defer s.writeMu.Unlock() + if _, err := s.conn.Write(header); err != nil { + return err + } + _, err := s.conn.Write(body) + return err +} + +// refreshCopyRectIndex does a full hash sweep of the just-swapped prevFrame. +// Used after full-frame sends, where we don't have a per-tile dirty list to +// drive an incremental update. +func (s *session) refreshCopyRectIndex() { + if s.copyRectDet == nil || s.prevFrame == nil { + return + } + s.copyRectDet.rebuild(s.prevFrame, s.serverW, s.serverH) +} + +// updateCopyRectIndex incrementally updates the CopyRect detector's hash +// tables for the tiles that just changed. On first use (or after resize) +// updateDirty internally falls back to a full rebuild. +func (s *session) updateCopyRectIndex(dirty [][4]int) { + if s.copyRectDet == nil || s.prevFrame == nil { + return + } + s.copyRectDet.updateDirty(s.prevFrame, s.serverW, s.serverH, dirty) +} + +// captureFrame returns a session-owned frame for this encode cycle. +// Capturers that implement captureIntoer (Linux X11, macOS) write directly +// into curFrame, saving a per-frame full-screen memcpy. Capturers that +// don't (Windows DXGI) return their own buffer which we copy into curFrame +// to keep the encoder's prevFrame stable across the next capture cycle. +func (s *session) captureFrame() (*image.RGBA, error) { + w, h := s.serverW, s.serverH + if s.curFrame == nil || s.curFrame.Rect.Dx() != w || s.curFrame.Rect.Dy() != h { + s.curFrame = image.NewRGBA(image.Rect(0, 0, w, h)) + } + + if ci, ok := s.capturer.(captureIntoer); ok { + if err := ci.CaptureInto(s.curFrame); err != nil { + return nil, err + } + return s.curFrame, nil + } + + src, err := s.capturer.Capture() + if err != nil { + return nil, err + } + if s.curFrame.Rect != src.Rect { + s.curFrame = image.NewRGBA(src.Rect) + } + copy(s.curFrame.Pix, src.Pix) + return s.curFrame, nil +} + +// shouldPromoteToFullFrame returns true when the dirty rect set covers a +// large enough fraction of the screen that a single full-frame zlib rect +// beats per-tile encoding on both CPU time and wire bytes. The crossover +// is measured via BenchmarkEncodeManyTilesVsFullFrame. +func (s *session) shouldPromoteToFullFrame(rects [][4]int) bool { + if s.serverW == 0 || s.serverH == 0 { + return false + } + var dirty int + for _, r := range rects { + dirty += r[2] * r[3] + } + return dirty*fullFramePromoteDen > s.serverW*s.serverH*fullFramePromoteNum +} + +// swapPrevCur makes the just-encoded frame the new prevFrame (for the next +// diff) and lets the old prevFrame buffer become the next curFrame. Avoids +// an 8 MB copy per frame compared to the old savePrevFrame path. +func (s *session) swapPrevCur() { + s.prevFrame, s.curFrame = s.curFrame, s.prevFrame +} + +// sendEmptyUpdate sends a FramebufferUpdate with zero rectangles. +func (s *session) sendEmptyUpdate() error { + var buf [4]byte + buf[0] = serverFramebufferUpdate + s.writeMu.Lock() + _, err := s.conn.Write(buf[:]) + s.writeMu.Unlock() + return err +} + +func (s *session) sendFullUpdate(img *image.RGBA) error { + w, h := s.serverW, s.serverH + + s.encMu.RLock() + pf := s.pf + useTight := s.useTight + tight := s.tight + s.encMu.RUnlock() + + if useTight && tight != nil && pfIsTightCompatible(pf) { + // Tight encodes arbitrary sizes natively (Fill for uniform, JPEG + // for photo-like, Basic+zlib otherwise). Wrap the rect bytes with + // the 4-byte FramebufferUpdate header. + rectBuf := encodeTightRect(img, pf, 0, 0, w, h, tight) + buf := make([]byte, 4+len(rectBuf)) + buf[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(buf[2:4], 1) + copy(buf[4:], rectBuf) + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err + } + + buf := encodeRawRect(img, pf, 0, 0, w, h) + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err +} + +// sendDirtyAndMoves writes one FramebufferUpdate combining CopyRect moves +// (cheap, 16 bytes each) and pixel-encoded dirty rects. Moves come first so +// their source tiles are read from the client's pre-update framebuffer state, +// before any subsequent rect overwrites them. +func (s *session) sendDirtyAndMoves(img *image.RGBA, moves []copyRectMove, rects [][4]int) error { + if len(moves) == 0 && len(rects) == 0 { + return nil + } + + total := len(moves) + len(rects) + header := make([]byte, 4) + header[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(header[2:4], uint16(total)) + + s.writeMu.Lock() + defer s.writeMu.Unlock() + + if _, err := s.conn.Write(header); err != nil { + return err + } + + ts := tileSize + for _, m := range moves { + body := encodeCopyRectBody(m.srcX, m.srcY, m.dstX, m.dstY, ts, ts) + if _, err := s.conn.Write(body); err != nil { + return err + } + } + + for _, r := range rects { + x, y, w, h := r[0], r[1], r[2], r[3] + rectBuf := s.encodeTile(img, x, y, w, h) + if _, err := s.conn.Write(rectBuf); err != nil { + return err + } + } + return nil +} + +// encodeTile produces the on-wire rect bytes for a single dirty tile. Tight +// is the only non-Raw encoding we negotiate: uniform tiles collapse to its +// Fill subencoding (~16 bytes), photo-like rects route to JPEG, and the +// rest take the Basic+zlib path. Raw is the fallback when Tight is not +// negotiated or the negotiated pixel format is incompatible with Tight's +// mandatory 24-bit RGB TPIXEL encoding. +// +// Output omits the 4-byte FramebufferUpdate header; callers combine multiple +// tiles into one message. +func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { + s.encMu.RLock() + pf := s.pf + useTight := s.useTight + tight := s.tight + s.encMu.RUnlock() + + if useTight && tight != nil && pfIsTightCompatible(pf) { + return encodeTightRect(img, pf, x, y, w, h, tight) + } + return encodeRawRect(img, pf, x, y, w, h)[4:] +} + +// drainRequests consumes any pending requests so the sender's close completes +// cleanly after the encoder loop has decided to exit on error. Returns the +// number of drained requests to defeat empty-block lints; callers ignore it. +func drainRequests(ch chan fbRequest) int { + var drained int + for range ch { + drained++ + } + return drained +} + +// pfIsTightCompatible reports whether the negotiated client pixel format +// matches Tight's TPIXEL constraint: standard RGB shifts (R=16, G=8, B=0). +// bpp/endianness/channel-max are already locked at SetPixelFormat time. +func pfIsTightCompatible(pf clientPixelFormat) bool { + return pf.rShift == 16 && pf.gShift == 8 && pf.bShift == 0 +} From ee393d0e629fe640bc9d7a6eb3037fe75a2e43ac Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 21:27:13 +0200 Subject: [PATCH 027/151] Clamp Tight length to 22 bits and fall back to Raw on overflow --- client/vnc/server/rfb.go | 47 +++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 12678e5fe59..6c8b200bec8 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -495,10 +495,17 @@ func zlibLevelFor(level int) int { return level } +// tightMaxLength is the maximum payload size representable in the Tight +// compact length prefix (RFB §7.7.6: 22 bits, three 7+7+8 bit groups). +// Exceeding this would silently truncate the high byte; callers must fall +// back to a different encoding when an attempt would overflow. +const tightMaxLength = (1 << 22) - 1 + // encodeTightRect emits a single Tight-encoded rect. Picks Fill for uniform // content, JPEG for photo-like rects above a size and color-count threshold, -// and Basic+zlib otherwise. Returns the rect header + Tight body (no -// FramebufferUpdate header). +// and Basic+zlib otherwise. When Tight's 22-bit length cap would be exceeded +// (huge full-frame rects under bad compression), falls back to Raw. Returns +// the rect header + body (no FramebufferUpdate header). func encodeTightRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, t *tightState) []byte { if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform { return encodeTightFill(x, y, w, h, byte(pixel), byte(pixel>>8), byte(pixel>>16)) @@ -508,7 +515,12 @@ func encodeTightRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, t *t return buf } } - return encodeTightBasic(img, x, y, w, h, t) + if buf, ok := encodeTightBasic(img, x, y, w, h, t); ok { + return buf + } + // Fall back to Raw rect body (skip the 4-byte FU header that encodeRawRect + // prepends, since callers compose their own FU header). + return encodeRawRect(img, pf, x, y, w, h)[4:] } func writeTightRectHeader(buf []byte, x, y, w, h int) { @@ -520,8 +532,13 @@ func writeTightRectHeader(buf []byte, x, y, w, h int) { } // appendTightLength encodes a Tight compact length prefix (1, 2, or 3 bytes -// LE-ish, top bit of each byte signals continuation). +// LE-ish, top bit of each byte signals continuation). Lengths exceeding +// tightMaxLength would silently truncate the high byte; callers must clamp +// or fall back before reaching here. func appendTightLength(buf []byte, n int) []byte { + if n < 0 || n > tightMaxLength { + panic(fmt.Sprintf("tight length out of range: %d", n)) + } b0 := byte(n & 0x7f) if n <= 0x7f { return append(buf, b0) @@ -532,6 +549,8 @@ func appendTightLength(buf []byte, n int) []byte { return append(buf, b0, b1) } b1 |= 0x80 + // High group is 8 bits per spec, but our cap guarantees the top 2 bits + // are zero; mask defensively. b2 := byte((n >> 14) & 0xff) return append(buf, b0, b1, b2) } @@ -562,6 +581,9 @@ func encodeTightJPEG(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bo return nil, false } jpegBytes := t.jpegBuf.Bytes() + if len(jpegBytes) > tightMaxLength { + return nil, false + } buf := make([]byte, 0, 12+1+3+len(jpegBytes)) buf = buf[:12] writeTightRectHeader(buf, x, y, w, h) @@ -574,8 +596,10 @@ func encodeTightJPEG(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bo // encodeTightBasic emits Basic+zlib with the no-op (CopyFilter) filter. // Pixels are sent as 24-bit RGB ("TPIXEL" format) which most clients // negotiate when the server advertises 32bpp true colour. Streams under -// 12 bytes ship uncompressed per RFB Tight spec. -func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) []byte { +// 12 bytes ship uncompressed per RFB Tight spec. Returns ok=false when the +// compressed payload would exceed Tight's 22-bit length cap or when zlib +// errors, signalling the caller to fall back to Raw. +func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bool) { pixelStream := w * h * 3 if cap(t.scratch) < pixelStream { t.scratch = make([]byte, pixelStream) @@ -605,20 +629,23 @@ func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) []byte { writeTightRectHeader(buf, x, y, w, h) buf = append(buf, subenc, filter) buf = append(buf, scratch...) - return buf + return buf, true } z := t.zlib z.buf.Reset() if _, err := z.w.Write(scratch); err != nil { log.Debugf("tight zlib write: %v", err) - return nil + return nil, false } if err := z.w.Flush(); err != nil { log.Debugf("tight zlib flush: %v", err) - return nil + return nil, false } compressed := z.buf.Bytes() + if len(compressed) > tightMaxLength { + return nil, false + } buf := make([]byte, 0, 12+2+5+len(compressed)) buf = buf[:12] @@ -626,7 +653,7 @@ func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) []byte { buf = append(buf, subenc, filter) buf = appendTightLength(buf, len(compressed)) buf = append(buf, compressed...) - return buf + return buf, true } func tightQualityFor(pixels int) int { From f5e1057127ea79c0372d0e36613641a0662d3cb6 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 07:41:12 +0200 Subject: [PATCH 028/151] Latin-1 round-trip for legacy CutText and soft-fail ext clipboard errors --- client/vnc/server/session_clipboard.go | 65 +++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/client/vnc/server/session_clipboard.go b/client/vnc/server/session_clipboard.go index 4d73421987f..3b3e13abe21 100644 --- a/client/vnc/server/session_clipboard.go +++ b/client/vnc/server/session_clipboard.go @@ -67,20 +67,68 @@ func (s *session) handleCutText() error { if _, err := io.ReadFull(s.conn, buf); err != nil { return fmt.Errorf("read CutText payload: %w", err) } - s.injector.SetClipboard(string(buf)) + s.injector.SetClipboard(latin1ToUTF8(buf)) return nil } +// drainBytes consumes and discards n bytes from the connection. Used to +// skip the payload of a malformed clipboard message after we've decided +// not to honour it, so the next message stays aligned. +func (s *session) drainBytes(n uint32) error { + if n == 0 { + return nil + } + if _, err := io.CopyN(io.Discard, s.conn, int64(n)); err != nil { + return fmt.Errorf("drain %d bytes: %w", n, err) + } + return nil +} + +// latin1ToUTF8 converts an RFB ClientCutText payload (ISO 8859-1 per +// RFC 6143 §7.5.6) into a UTF-8 string. Bytes 0x80..0xFF map to the +// matching U+0080..U+00FF code points; passing them through Go's +// `string([]byte)` instead would produce invalid UTF-8 that downstream +// clipboard backends mangle. +func latin1ToUTF8(b []byte) string { + runes := make([]rune, len(b)) + for i, c := range b { + runes[i] = rune(c) + } + return string(runes) +} + +// utf8ToLatin1 converts a UTF-8 string into the Latin-1 byte sequence +// required by legacy ServerCutText (RFC 6143 §7.6.4). Runes outside +// U+0000..U+00FF are not representable in Latin-1; we substitute '?' so the +// peer still receives a coherent message instead of a truncated or +// silently mojibake'd payload. ExtendedClipboard clients take a separate +// path that preserves full UTF-8. +func utf8ToLatin1(s string) []byte { + out := make([]byte, 0, len(s)) + for _, r := range s { + if r > 0xFF { + out = append(out, '?') + continue + } + out = append(out, byte(r)) + } + return out +} + // handleExtCutText parses an ExtendedClipboard message (any of Caps, // Notify, Request, Peek, Provide) carried as a negative-length CutText. -// Unknown actions and formats we don't handle (RTF/HTML/DIB/Files) are -// dropped without aborting the session. +// Unknown actions, oversized payloads, and formats we don't handle +// (RTF/HTML/DIB/Files) are logged and dropped instead of aborting the +// session: a malformed clipboard message must never cost the user their +// VNC connection. Read errors on the socket itself still propagate. func (s *session) handleExtCutText(payloadLen uint32) error { if payloadLen < 4 { - return fmt.Errorf("ext clipboard payload too short: %d", payloadLen) + s.log.Debugf("ext clipboard payload too short: %d", payloadLen) + return s.drainBytes(payloadLen) } if payloadLen > extClipMaxPayload { - return fmt.Errorf("ext clipboard payload too large: %d", payloadLen) + s.log.Debugf("ext clipboard payload too large: %d", payloadLen) + return s.drainBytes(payloadLen) } buf := make([]byte, payloadLen) if _, err := io.ReadFull(s.conn, buf); err != nil { @@ -187,9 +235,12 @@ func (s *session) handleTypeText() error { return nil } -// sendServerCutText sends clipboard text from the server to the client. +// sendServerCutText sends clipboard text from the server to the legacy +// (non-ExtendedClipboard) client. The wire encoding is Latin-1; runes that +// fall outside U+0000..U+00FF are best-effort replaced with '?' since the +// peer cannot represent them. func (s *session) sendServerCutText(text string) error { - data := []byte(text) + data := utf8ToLatin1(text) buf := make([]byte, 8+len(data)) buf[0] = serverCutText // buf[1:4] = padding (zero) From bfb6750b138850aa85deac8f27e7c075b7c70701 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 07:41:42 +0200 Subject: [PATCH 029/151] Reset encoding capability flags on each SetEncodings --- client/vnc/server/session.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 1c250c4fc82..697fabe766c 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -277,6 +277,10 @@ func (s *session) handleSetEncodings() error { var encs []string s.encMu.Lock() + // Per RFC 6143 §7.5.3 each SetEncodings replaces the previous list, so + // reset all flags before re-applying. extClipCapsSent stays sticky so + // we don't re-emit Caps every refresh. + s.resetEncodingCaps() for i := range int(numEnc) { enc := int32(binary.BigEndian.Uint32(buf[i*4 : i*4+4])) if name := s.applyEncoding(enc); name != "" { @@ -304,6 +308,23 @@ func (s *session) handleSetEncodings() error { return nil } +// resetEncodingCaps zeroes the encoding capability flags so the next pass +// through applyEncoding reflects exactly what the client just advertised. +// Caller holds s.encMu. tight / copyRectDet allocations are kept; their +// runtime use is gated by the boolean flags here. +func (s *session) resetEncodingCaps() { + s.useTight = false + s.useCopyRect = false + s.clientSupportsDesktopSize = false + s.clientSupportsExtendedDesktopSize = false + s.clientSupportsDesktopName = false + s.clientSupportsLastRect = false + s.clientSupportsQEMUKey = false + s.clientSupportsExtClipboard = false + s.clientJPEGQuality = -1 + s.clientZlibLevel = -1 +} + // applyEncoding records a single encoding/pseudo-encoding from a SetEncodings // message. Returns the short name used in the debug log, or "" if the value // is one we don't recognise. Caller holds s.encMu. From 785f94d13ff8d9a554b08b4598cbfb691c8b5227 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 07:42:24 +0200 Subject: [PATCH 030/151] Guard buildExtClipProvideText against oversized input --- client/vnc/server/extclipboard.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/vnc/server/extclipboard.go b/client/vnc/server/extclipboard.go index 171430e776c..86ab6d55410 100644 --- a/client/vnc/server/extclipboard.go +++ b/client/vnc/server/extclipboard.go @@ -88,8 +88,12 @@ func buildExtClipRequest(formats uint32) []byte { // buildExtClipProvideText emits a Provide carrying UTF-8 text. The inner // stream (4-byte length including the trailing NUL, then UTF-8 bytes, then // NUL) is zlib-compressed; each Provide uses an independent zlib context -// per the extension spec. +// per the extension spec. Rejects oversized input so a caller bug can't +// produce a payload larger than the size advertised in our Caps. func buildExtClipProvideText(text string) ([]byte, error) { + if len(text) > extClipMaxText { + return nil, fmt.Errorf("clipboard text exceeds extClipMaxText (%d > %d)", len(text), extClipMaxText) + } body := make([]byte, 0, 4+len(text)+1) var lenBuf [4]byte binary.BigEndian.PutUint32(lenBuf[:], uint32(len(text)+1)) From 7e7e056f3a7f4de3e8ade1f41e5b0262545b26ea Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 07:54:21 +0200 Subject: [PATCH 031/151] Reset Tight zlib stream when deflater is recreated mid-session Also scrub brand-name references from comments. --- client/vnc/server/extclipboard.go | 10 +++++----- client/vnc/server/input_darwin.go | 6 +++--- client/vnc/server/input_windows.go | 2 +- client/vnc/server/rfb.go | 23 +++++++++++++++++------ client/vnc/server/server.go | 2 +- client/vnc/server/session.go | 10 +++++++++- 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/client/vnc/server/extclipboard.go b/client/vnc/server/extclipboard.go index 86ab6d55410..38234b007b5 100644 --- a/client/vnc/server/extclipboard.go +++ b/client/vnc/server/extclipboard.go @@ -54,11 +54,11 @@ const ( // buildExtClipCaps emits the Caps payload. The flags word advertises every // action we support in the high byte (Caps + Request + Peek + Notify + -// Provide) and every format we accept in the low 16 bits. noVNC uses these -// action bits to decide whether to auto-Request on Notify; without -// Request in our Caps it silently drops our Notify messages. After the -// flags word we emit one uint32 max size per format bit set, in ascending -// bit order. +// Provide) and every format we accept in the low 16 bits. Clients use +// these action bits to decide whether to auto-Request on Notify; without +// Request in our Caps a conforming client silently drops our Notify +// messages. After the flags word we emit one uint32 max size per format +// bit set, in ascending bit order. func buildExtClipCaps() []byte { flags := extClipActionCaps | extClipActionRequest | extClipActionPeek | extClipActionNotify | extClipActionProvide | extClipFormatText diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 595219745d3..1bda2285c30 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -632,10 +632,10 @@ var specialKeyMap = map[uint32]uint16{ 0xffea: 0x3D, // Alt_R (Option) 0xffe7: 0x37, // Meta_L (Command) 0xffe8: 0x36, // Meta_R (Command) - 0xffeb: 0x37, // Super_L (Command) - noVNC sends this + 0xffeb: 0x37, // Super_L (Command) 0xffec: 0x36, // Super_R (Command) - // Mode_switch / ISO_Level3_Shift (sent by noVNC for macOS Option remap) + // Mode_switch / ISO_Level3_Shift (for macOS Option remap on layouts) 0xff7e: 0x3A, // Mode_switch -> Option 0xfe03: 0x3D, // ISO_Level3_Shift -> Right Option @@ -674,7 +674,7 @@ var specialKeyMap = map[uint32]uint16{ 0x002e: 0x2F, // period . 0x002f: 0x2C, // slash / - // Shifted punctuation (noVNC sends these as separate keysyms) + // Shifted punctuation (clients sometimes send these as separate keysyms) 0x005f: 0x1B, // underscore _ (shift+minus) 0x002b: 0x18, // plus + (shift+equal) 0x007b: 0x21, // braceleft { (shift+[) diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index 9f6af408980..aeeb35be639 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -206,7 +206,7 @@ func (w *WindowsInputInjector) InjectKey(keysym uint32, down bool) { // InjectKeyScancode queues a raw-scancode key event. PC AT Set 1 maps // directly onto what SendInput's KEYEVENTF_SCANCODE flag wants, so the // only translation is splitting the optional 0xE0 prefix off into the -// KEYEVENTF_EXTENDEDKEY flag. keysym is the noVNC-provided fallback we +// KEYEVENTF_EXTENDEDKEY flag. keysym is the client-provided fallback we // reach for if the scancode is zero. func (w *WindowsInputInjector) InjectKeyScancode(scancode uint32, keysym uint32, down bool) { if scancode == 0 { diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 6c8b200bec8..93e71d84f6d 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -69,10 +69,9 @@ const ( pseudoEncDesktopName = -307 pseudoEncExtendedDesktopSize = -308 - // Quality/Compression level pseudo-encodings (TightVNC extension). The - // client picks one value from each range to tune JPEG quality and zlib - // effort. 0 is lowest quality / fastest, 9 is highest quality / best - // compression. + // Quality/Compression level pseudo-encodings. The client picks one + // value from each range to tune JPEG quality and zlib effort. 0 is + // lowest quality / fastest, 9 is highest quality / best compression. pseudoEncQualityLevelMin = -32 pseudoEncQualityLevelMax = -23 pseudoEncCompressLevelMin = -256 @@ -445,6 +444,12 @@ type tightState struct { // whether a SetEncodings refresh needs to recreate the tight state. qualityLevel int compressLevel int + // pendingZlibReset becomes true when this tightState replaces an + // in-use one (e.g. CompressLevel change mid-session). The next Basic + // rect we emit ORs the stream-0 reset bit into its sub-encoding byte + // so the client's inflater drops its now-stale dictionary; cleared + // after one emission. + pendingZlibReset bool } func newTightState() *tightState { @@ -618,9 +623,15 @@ func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, b } } - // Sub-encoding byte: stream 0, no resets, basic encoding (top nibble - // = 0x40 = explicit filter follows). + // Sub-encoding byte: stream 0, basic encoding (top nibble = 0x40 = + // explicit filter follows). The low nibble carries per-stream reset + // flags; bit 0 here tells the client to reset its stream-0 inflater + // when our deflater was just recreated. subenc := byte(tightBasicFilter) + if t.pendingZlibReset { + subenc |= 0x01 + t.pendingZlibReset = false + } filter := byte(tightFilterCopy) if pixelStream < 12 { diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 1bda413e8ac..cdcea9570ce 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -32,7 +32,7 @@ const ( ) // RFB security-failure reason codes sent to the client. These prefixes are -// stable so dashboard/noVNC integrations can branch on them without parsing +// stable so dashboard integrations can branch on them without parsing // free text. Format: "CODE: human message". const ( RejectCodeJWTMissing = "AUTH_JWT_MISSING" diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 697fabe766c..6abdef2941e 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -77,7 +77,7 @@ type session struct { // captureErrLast throttles "capture (transient)" logs while the // capturer is in a sustained failure state (e.g. X server died but a - // noVNC tab is still open). Owned by the encoder goroutine. + // client is still connected). Owned by the encoder goroutine. captureErrLast time.Time captureErrSeen bool @@ -290,7 +290,15 @@ func (s *session) handleSetEncodings() error { if s.useTight && (s.tight == nil || s.tight.qualityLevel != s.clientJPEGQuality || s.tight.compressLevel != s.clientZlibLevel) { + // When we replace an in-use tightState the client's stream-0 + // inflater carries dictionary state from the old deflater. Carry + // the pending-reset flag so the next Basic rect tells the client + // to reset its inflater before decoding. + replacing := s.tight != nil s.tight = newTightStateWithLevels(s.clientJPEGQuality, s.clientZlibLevel) + if replacing { + s.tight.pendingZlibReset = true + } } sendExtClipCaps := s.clientSupportsExtClipboard && !s.extClipCapsSent if sendExtClipCaps { From 97d0a6776fe6b74306033728ac215c93ddd49c78 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 08:55:27 +0200 Subject: [PATCH 032/151] Release sticky modifiers and mouse buttons on client disconnect --- client/vnc/server/session.go | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 6abdef2941e..eae4fa85ef5 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -85,6 +85,13 @@ type session struct { // encoder goroutine. Buffered size 1: RFB clients have one outstanding // request at a time, so a new request always replaces any pending one. encodeCh chan fbRequest + + // pointerMu guards the cached last cursor position used by + // releaseStickyInput so the disconnect-time button-release event + // targets the cursor's current spot instead of warping to (0, 0). + pointerMu sync.Mutex + lastPointerX int + lastPointerY int } type fbRequest struct { @@ -107,6 +114,12 @@ func (s *session) serve() { } s.log.Infof("client connected: %s", s.addr()) + // On any exit path (clean disconnect, transport error, panic) release + // modifier keys and mouse buttons so the host doesn't end up with + // Shift/Ctrl/Alt or a mouse button stuck because the client dropped + // while holding them. + defer s.releaseStickyInput() + done := make(chan struct{}) defer close(done) go s.clipboardPoll(done) @@ -470,6 +483,40 @@ func (s *session) handlePointerEvent() error { buttonMask := data[0] x := int(binary.BigEndian.Uint16(data[1:3])) y := int(binary.BigEndian.Uint16(data[3:5])) + s.pointerMu.Lock() + s.lastPointerX = x + s.lastPointerY = y + s.pointerMu.Unlock() s.injector.InjectPointer(buttonMask, x, y, s.serverW, s.serverH) return nil } + +// stickyModifierKeysyms are the X11 keysyms we send "up" events for on +// disconnect. Modifier-up while not held is a no-op on every supported +// platform, so we can blanket-release without per-key tracking. This +// covers the practical sticky-state bug: client drops while user is +// holding Shift / Ctrl / Alt / Meta / Super. +var stickyModifierKeysyms = [...]uint32{ + 0xffe1, 0xffe2, // Shift_L, Shift_R + 0xffe3, 0xffe4, // Control_L, Control_R + 0xffe9, 0xffea, // Alt_L, Alt_R + 0xffe7, 0xffe8, // Meta_L, Meta_R + 0xffeb, 0xffec, // Super_L, Super_R + 0xff7e, // Mode_switch + 0xfe03, // ISO_Level3_Shift (AltGr) + 0xffe5, // Caps_Lock (release if user dropped mid-press) +} + +// releaseStickyInput synthesizes key-up for modifier keysyms and a +// zero-button PointerEvent so the host doesn't end up with stuck input +// when the client disconnects mid-press. Mouse coordinates are reused +// from the last PointerEvent so we don't warp the cursor. +func (s *session) releaseStickyInput() { + for _, ks := range stickyModifierKeysyms { + s.injector.InjectKey(ks, false) + } + s.pointerMu.Lock() + x, y := s.lastPointerX, s.lastPointerY + s.pointerMu.Unlock() + s.injector.InjectPointer(0, x, y, s.serverW, s.serverH) +} From b9f5264e36a414df0f6532d38d8e95b9e87f2b8a Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 12:19:22 +0200 Subject: [PATCH 033/151] Restore createRDPProxy wasm entry point for dashboard RDP --- client/wasm/cmd/main.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 9bd5fbb7dd3..b7a92f3a15e 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -19,6 +19,7 @@ import ( nbstatus "github.com/netbirdio/netbird/client/status" wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture" "github.com/netbirdio/netbird/client/wasm/internal/http" + "github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/ssh" "github.com/netbirdio/netbird/client/wasm/internal/vnc" "github.com/netbirdio/netbird/util" @@ -364,6 +365,29 @@ func createProxyRequestMethod(client *netbird.Client) js.Func { }) } +// createRDPProxyMethod creates the RDP proxy method +func createRDPProxyMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 2 { + return js.ValueOf("error: hostname and port required") + } + + if args[0].Type() != js.TypeString { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("hostname parameter must be a string")) + }) + } + if args[1].Type() != js.TypeString { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("port parameter must be a string")) + }) + } + + proxy := rdp.NewRDCleanPathProxy(client) + return proxy.CreateProxy(args[0].String(), args[1].String()) + }) +} + // createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging. // JS signature: createVNCProxy(hostname, port, mode?, username?, jwt?, sessionID?, width?, height?) // mode: "attach" (default) or "session" @@ -780,6 +804,7 @@ func createClientObject(client *netbird.Client) js.Value { obj["detectSSHServerType"] = createDetectSSHServerMethod(client) obj["createSSHConnection"] = createSSHMethod(client) obj["proxyRequest"] = createProxyRequestMethod(client) + obj["createRDPProxy"] = createRDPProxyMethod(client) obj["createVNCProxy"] = createVNCProxyMethod(client) obj["status"] = createStatusMethod(client) obj["statusSummary"] = createStatusSummaryMethod(client) From c2fdf62f1fd7a9e52eddb18004b619006f7190bd Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 12:39:51 +0200 Subject: [PATCH 034/151] Detect dead VNC peers on both ends and report session stats --- client/internal/engine_vnc.go | 15 +++ client/internal/metrics/influxdb.go | 30 +++++ client/internal/metrics/metrics.go | 31 +++++ client/internal/metrics/push_test.go | 3 + client/vnc/server/metrics_conn.go | 187 +++++++++++++++++++++++++++ client/vnc/server/server.go | 80 +++++++++++- client/vnc/server/server_windows.go | 2 + client/wasm/cmd/main.go | 10 +- client/wasm/internal/vnc/proxy.go | 37 ++++-- 9 files changed, 372 insertions(+), 23 deletions(-) create mode 100644 client/vnc/server/metrics_conn.go diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index d162f27cbb5..fa62f8396a9 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -11,6 +11,7 @@ import ( log "github.com/sirupsen/logrus" firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/metrics" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" sshauth "github.com/netbirdio/netbird/client/ssh/auth" vncserver "github.com/netbirdio/netbird/client/vnc/server" @@ -102,6 +103,20 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { netbirdIP := e.wgInterface.Address().IP srv := vncserver.New(capturer, injector) + if e.clientMetrics != nil { + srv.SetSessionRecorder(func(t vncserver.SessionTick) { + e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{ + Period: t.Period, + BytesOut: t.BytesOut, + Writes: t.Writes, + FBUs: t.FBUs, + MaxFBUBytes: t.MaxFBUBytes, + MaxFBURects: t.MaxFBURects, + MaxWriteBytes: t.MaxWriteBytes, + WriteNanos: t.WriteNanos, + }) + }) + } if vncNeedsServiceMode() { log.Info("VNC: running in Session 0, enabling service mode (agent proxy)") srv.SetServiceMode(true) diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go index 531f6a9867c..dd7c24907da 100644 --- a/client/internal/metrics/influxdb.go +++ b/client/internal/metrics/influxdb.go @@ -120,6 +120,36 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI m.trimLocked() } +func (m *influxDBMetrics) RecordVNCSessionTick(_ context.Context, agentInfo AgentInfo, tick VNCSessionTick) { + tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s", + agentInfo.DeploymentType.String(), + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_vnc_traffic", + tags: tags, + fields: map[string]float64{ + "period_seconds": tick.Period.Seconds(), + "bytes_out": float64(tick.BytesOut), + "writes": float64(tick.Writes), + "fbus": float64(tick.FBUs), + "max_fbu_bytes": float64(tick.MaxFBUBytes), + "max_fbu_rects": float64(tick.MaxFBURects), + "max_write_bytes": float64(tick.MaxWriteBytes), + "write_time_seconds": float64(tick.WriteNanos) / 1e9, + }, + timestamp: time.Now(), + }) + m.trimLocked() +} + func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { result := "success" if !success { diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index 4ebb4349659..7a0ed29129b 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -59,6 +59,11 @@ type metricsImplementation interface { // RecordLoginDuration records how long the login to management took RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) + // RecordVNCSessionTick records a periodic snapshot of one VNC + // session's wire activity. Called once per metricsConn tick interval + // (and once at session close), only when the tick saw activity. + RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick) + // Export exports metrics in InfluxDB line protocol format Export(w io.Writer) error @@ -78,6 +83,21 @@ type ClientMetrics struct { pushCancel context.CancelFunc } +// VNCSessionTick is one sampling slice of a VNC session's wire activity. +// BytesOut / Writes / FBUs / WriteNanos are deltas observed during this +// tick; Max* fields are the high-water marks observed during the tick. +// Period is the wall-clock duration the deltas cover. +type VNCSessionTick struct { + Period time.Duration + BytesOut uint64 + Writes uint64 + FBUs uint64 + MaxFBUBytes uint64 + MaxFBURects uint64 + MaxWriteBytes uint64 + WriteNanos uint64 +} + // ConnectionStageTimestamps holds timestamps for each connection stage type ConnectionStageTimestamps struct { SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection) @@ -127,6 +147,17 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du c.impl.RecordSyncDuration(ctx, agentInfo, duration) } +// RecordVNCSessionTick records a periodic snapshot of one VNC session. +func (c *ClientMetrics) RecordVNCSessionTick(ctx context.Context, tick VNCSessionTick) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + c.impl.RecordVNCSessionTick(ctx, agentInfo, tick) +} + // RecordLoginDuration records how long the login to management server took func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) { if c == nil { diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go index 20a509da16a..e9f8cb976bc 100644 --- a/client/internal/metrics/push_test.go +++ b/client/internal/metrics/push_test.go @@ -73,6 +73,9 @@ func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time. func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) { } +func (m *mockMetrics) RecordVNCSessionTick(_ context.Context, _ AgentInfo, _ VNCSessionTick) { +} + func (m *mockMetrics) Export(w io.Writer) error { if m.exportData != "" { _, err := w.Write([]byte(m.exportData)) diff --git a/client/vnc/server/metrics_conn.go b/client/vnc/server/metrics_conn.go new file mode 100644 index 00000000000..bc0f36d5711 --- /dev/null +++ b/client/vnc/server/metrics_conn.go @@ -0,0 +1,187 @@ +//go:build !js && !ios && !android + +package server + +import ( + "net" + "sync" + "sync/atomic" + "time" +) + +// SessionTick is one sampling slice of a VNC session's wire activity. +// BytesOut / Writes / FBUs are deltas observed during this tick; +// Max* fields are the high-water marks observed during this tick (reset +// at the start of the next). Period is the wall-clock duration covered +// (typically sessionTickInterval, shorter for the final flush). +type SessionTick struct { + Period time.Duration + BytesOut uint64 + Writes uint64 + FBUs uint64 + MaxFBUBytes uint64 + MaxFBURects uint64 + MaxWriteBytes uint64 + WriteNanos uint64 +} + +// sessionTickInterval is how often metricsConn emits a SessionTick. One +// second matches noVNC's request cadence so each tick covers roughly one +// FBU round-trip during steady-state activity. +const sessionTickInterval = time.Second + +// metricsConn wraps a net.Conn and tracks per-session byte / write / FBU +// counters. Updates are atomic so the cost is a few atomic ops per Write +// (well under 100 ns), negligible against the syscall itself, so the wrap +// is always installed. A goroutine emits a SessionTick to the recorder +// every sessionTickInterval (only when the tick has activity to report); +// a final partial-tick flush runs on Close. +type metricsConn struct { + net.Conn + + recorder func(SessionTick) + + bytesOut uint64 + writes uint64 + writeNanos uint64 + largestPkt uint64 + fbus uint64 + fbuBytes uint64 + fbuRects uint64 + maxFBUBytes uint64 + maxFBURects uint64 + + tickMu sync.Mutex + tickStart time.Time + tickPrevB uint64 + tickPrevW uint64 + tickPrevF uint64 + tickPrevNS uint64 + + closeOnce sync.Once + done chan struct{} +} + +func newMetricsConn(c net.Conn, recorder func(SessionTick)) net.Conn { + m := &metricsConn{ + Conn: c, + recorder: recorder, + tickStart: time.Now(), + done: make(chan struct{}), + } + if recorder != nil { + go m.tickLoop() + } + return m +} + +// tickLoop emits a SessionTick every sessionTickInterval until done. +// Empty ticks (no writes since the last tick) are skipped. +func (m *metricsConn) tickLoop() { + t := time.NewTicker(sessionTickInterval) + defer t.Stop() + for { + select { + case <-m.done: + return + case <-t.C: + m.flushTick(false) + } + } +} + +// flushTick computes deltas since the last tick, resets the per-tick max +// trackers, and emits a SessionTick to the recorder. final=true forces +// emission even if no writes happened (used at session close to record +// the trailing partial period). +func (m *metricsConn) flushTick(final bool) { + m.tickMu.Lock() + defer m.tickMu.Unlock() + + b := atomic.LoadUint64(&m.bytesOut) + w := atomic.LoadUint64(&m.writes) + f := atomic.LoadUint64(&m.fbus) + ns := atomic.LoadUint64(&m.writeNanos) + + db := b - m.tickPrevB + dw := w - m.tickPrevW + df := f - m.tickPrevF + dns := ns - m.tickPrevNS + m.tickPrevB, m.tickPrevW, m.tickPrevF, m.tickPrevNS = b, w, f, ns + + maxFBU := atomic.SwapUint64(&m.maxFBUBytes, 0) + maxRects := atomic.SwapUint64(&m.maxFBURects, 0) + maxPkt := atomic.SwapUint64(&m.largestPkt, 0) + + period := time.Since(m.tickStart) + m.tickStart = time.Now() + + if dw == 0 && !final { + return + } + m.recorder(SessionTick{ + Period: period, + BytesOut: db, + Writes: dw, + FBUs: df, + MaxFBUBytes: maxFBU, + MaxFBURects: maxRects, + MaxWriteBytes: maxPkt, + WriteNanos: dns, + }) +} + +// isFBUHeader reports whether the given Write payload is the 4-byte +// FramebufferUpdate header (message type 0, padding 0, rect-count high +// byte). Rect bodies are written separately by sendDirtyAndMoves, so the +// FBU/rect boundary lines up with Write boundaries. +func isFBUHeader(p []byte) bool { + return len(p) == 4 && p[0] == serverFramebufferUpdate +} + +func (m *metricsConn) Write(p []byte) (int, error) { + if isFBUHeader(p) { + if b := atomic.SwapUint64(&m.fbuBytes, 0); b > 0 { + if b > atomic.LoadUint64(&m.maxFBUBytes) { + atomic.StoreUint64(&m.maxFBUBytes, b) + } + } + if r := atomic.SwapUint64(&m.fbuRects, 0); r > 0 { + if r > atomic.LoadUint64(&m.maxFBURects) { + atomic.StoreUint64(&m.maxFBURects, r) + } + } + atomic.AddUint64(&m.fbus, 1) + } + + t0 := time.Now() + n, err := m.Conn.Write(p) + atomic.AddUint64(&m.writeNanos, uint64(time.Since(t0).Nanoseconds())) + atomic.AddUint64(&m.bytesOut, uint64(n)) + atomic.AddUint64(&m.writes, 1) + if !isFBUHeader(p) { + atomic.AddUint64(&m.fbuBytes, uint64(n)) + atomic.AddUint64(&m.fbuRects, 1) + } + if uint64(n) > atomic.LoadUint64(&m.largestPkt) { + atomic.StoreUint64(&m.largestPkt, uint64(n)) + } + return n, err +} + +func (m *metricsConn) Close() error { + m.closeOnce.Do(func() { + close(m.done) + if m.recorder == nil { + return + } + if b := atomic.SwapUint64(&m.fbuBytes, 0); b > atomic.LoadUint64(&m.maxFBUBytes) { + atomic.StoreUint64(&m.maxFBUBytes, b) + } + if r := atomic.SwapUint64(&m.fbuRects, 0); r > atomic.LoadUint64(&m.maxFBURects) { + atomic.StoreUint64(&m.maxFBURects, r) + } + m.flushTick(true) + }) + return m.Conn.Close() +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index cdcea9570ce..0cbd951826a 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -156,9 +156,15 @@ type Server struct { netstackNet *netstack.Net agentToken []byte // raw token bytes for agent-mode auth - sessionsMu sync.Mutex - sessionSeq uint64 - sessions map[uint64]ActiveSessionInfo + sessionsMu sync.Mutex + sessionSeq uint64 + sessions map[uint64]ActiveSessionInfo + sessionConns map[uint64]net.Conn + + // sessionRecorder, when non-nil, receives a SessionTick periodically + // during each VNC session and on session close. The engine wires + // this to its metrics framework. + sessionRecorder func(SessionTick) } // ActiveSessionInfo describes a currently connected VNC client. @@ -195,7 +201,8 @@ func New(capturer ScreenCapturer, injector InputInjector) *Server { injector: injector, authorizer: sshauth.NewAuthorizer(), log: log.WithField("component", "vnc-server"), - sessions: make(map[uint64]ActiveSessionInfo), + sessions: make(map[uint64]ActiveSessionInfo), + sessionConns: make(map[uint64]net.Conn), } } @@ -210,12 +217,13 @@ func (s *Server) ActiveSessions() []ActiveSessionInfo { return out } -func (s *Server) addSession(info ActiveSessionInfo) uint64 { +func (s *Server) addSession(info ActiveSessionInfo, conn net.Conn) uint64 { s.sessionsMu.Lock() defer s.sessionsMu.Unlock() s.sessionSeq++ id := s.sessionSeq s.sessions[id] = info + s.sessionConns[id] = conn return id } @@ -223,6 +231,24 @@ func (s *Server) removeSession(id uint64) { s.sessionsMu.Lock() defer s.sessionsMu.Unlock() delete(s.sessions, id) + delete(s.sessionConns, id) +} + +// closeActiveSessions closes every active session's connection so the +// per-session serve goroutines unblock from their Read loops and exit. +// Called from Stop to make sure clients see an immediate disconnect when +// the server is brought down, instead of waiting for the OS to reclaim +// the sockets after process exit. +func (s *Server) closeActiveSessions() { + s.sessionsMu.Lock() + conns := make([]net.Conn, 0, len(s.sessionConns)) + for _, c := range s.sessionConns { + conns = append(conns, c) + } + s.sessionsMu.Unlock() + for _, c := range conns { + _ = c.Close() + } } // SetServiceMode enables proxy-to-agent mode for Windows service operation. @@ -230,6 +256,14 @@ func (s *Server) SetServiceMode(enabled bool) { s.serviceMode = enabled } +// SetSessionRecorder installs a callback that receives a SessionTick +// each sessionTickInterval during a VNC session and one final tick on +// session close. Pass nil to disable. Empty ticks (no wire activity) +// are skipped. +func (s *Server) SetSessionRecorder(recorder func(SessionTick)) { + s.sessionRecorder = recorder +} + // SetJWTConfig configures JWT authentication for VNC connections. // Pass nil to disable JWT (public mode). func (s *Server) SetJWTConfig(config *JWTConfig) { @@ -340,6 +374,13 @@ func (s *Server) Stop() error { s.cancel = nil } + // Close active client connections before tearing down capturers and + // listeners. The per-session serve goroutines unblock from their Read + // loop with an error and run their deferred conn.Close, which surfaces + // a clean disconnect on the client side instead of leaving the + // connection hanging until the OS reclaims it on process exit. + s.closeActiveSessions() + if s.vmgr != nil { s.vmgr.StopAll() } @@ -378,10 +419,36 @@ func (s *Server) acceptLoop() { continue } + enableTCPKeepAlive(conn, s.log) go s.handleConnection(conn) } } +// vncKeepAlivePeriod controls how often TCP layer probes are sent on an +// idle connection. Default OS settings (2 hours) are too long for an +// interactive session: when the server-side host dies without sending FIN +// (power loss, network partition, hung kernel), the client only learns of +// the dead connection when the OS gives up on a probe. 30 s here means +// most clients notice within ~3 minutes worst case. +const vncKeepAlivePeriod = 30 * time.Second + +// enableTCPKeepAlive turns on SO_KEEPALIVE on the underlying TCP socket. +// Non-TCP conns (e.g. the netstack-backed listener) are skipped silently; +// keepalive there is the netstack's concern. +func enableTCPKeepAlive(c net.Conn, log *log.Entry) { + tc, ok := c.(*net.TCPConn) + if !ok { + return + } + if err := tc.SetKeepAlive(true); err != nil { + log.Debugf("set keepalive: %v", err) + return + } + if err := tc.SetKeepAlivePeriod(vncKeepAlivePeriod); err != nil { + log.Debugf("set keepalive period: %v", err) + } +} + func (s *Server) validateCapturer(capturer ScreenCapturer) error { // Quick check first: if already ready, return immediately. if capturer.Width() > 0 && capturer.Height() > 0 { @@ -472,7 +539,7 @@ func (s *Server) handleConnection(conn net.Conn) { Mode: modeString(header.mode), Username: header.username, JWTUsername: jwtUserID, - }) + }, conn) defer s.removeSession(sessionID) if err := s.validateCapturer(capturer); err != nil { @@ -481,6 +548,7 @@ func (s *Server) handleConnection(conn net.Conn) { return } + conn = newMetricsConn(conn, s.sessionRecorder) sess := &session{ conn: conn, capturer: capturer, diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 6caec5cdd96..efff86a9a66 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -255,6 +255,8 @@ func (s *Server) serviceAcceptLoop() { continue } + enableTCPKeepAlive(conn, s.log) + conn = newMetricsConn(conn, s.sessionRecorder) go s.handleServiceConnection(conn, sm) } } diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index b7a92f3a15e..aed7d2b2a84 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -691,10 +691,10 @@ func createStartCaptureMethod(client *netbird.Client) js.Func { // // Usage from browser devtools console: // -// await client.capture() // capture all packets -// await client.capture("tcp") // capture with filter -// await client.capture({filter: "host 10.0.0.1", verbose: true}) -// client.stopCapture() // stop and print stats +// await netbird.capture() // capture all packets +// await netbird.capture("tcp") // capture with filter +// await netbird.capture({filter: "host 10.0.0.1", verbose: true}) +// netbird.stopCapture() // stop and print stats func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) { var mu sync.Mutex var active *wasmcapture.Handle @@ -722,7 +722,7 @@ func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) { active = h console := js.Global().Get("console") - console.Call("log", "[capture] started, call client.stopCapture() to stop") + console.Call("log", "[capture] started, call netbird.stopCapture() to stop") resolve.Invoke(js.Undefined()) }) }) diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 5d9b58a2ad1..84718383c53 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -4,6 +4,7 @@ package vnc import ( "context" + "errors" "fmt" "io" "net" @@ -23,6 +24,15 @@ const ( // Connection modes matching server/server.go constants. modeAttach byte = 0 modeSession byte = 1 + + // WebSocket close codes the dashboard branches on. Codes 1000-1015 + // are reserved by RFC 6455; 4000-4999 are application-defined. + wsCodeNormal = 1000 + wsCodeAbnormal = 1006 + wsCodeDialTimeout = 4001 + wsCodeDialFailure = 4002 + wsCodeSessionSetup = 4003 + wsCodeTransport = 4004 ) // VNCProxy bridges WebSocket connections from noVNC in the browser @@ -245,8 +255,12 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) { if err != nil { log.Errorf("VNC connect to %s: %v", conn.destination.address, err) // Close the WebSocket so noVNC fires a disconnect event. + code := wsCodeDialFailure + if errors.Is(err, context.DeadlineExceeded) { + code = wsCodeDialTimeout + } if conn.wsHandlers.Get("close").Truthy() { - conn.wsHandlers.Call("close", 1006, fmt.Sprintf("connect to peer: %v", err)) + conn.wsHandlers.Call("close", code, fmt.Sprintf("connect to peer: %v", err)) } p.cleanupConnection(conn) return @@ -259,7 +273,7 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) { if err := p.sendSessionHeader(vncConn, conn.destination); err != nil { log.Errorf("send VNC session header: %v", err) if conn.wsHandlers.Get("close").Truthy() { - conn.wsHandlers.Call("close", 1006, fmt.Sprintf("send session header: %v", err)) + conn.wsHandlers.Call("close", wsCodeSessionSetup, fmt.Sprintf("send session header: %v", err)) } p.cleanupConnection(conn) return @@ -359,24 +373,23 @@ func (c *vncConnection) snapshotVNC() (net.Conn, bool) { } // handleConnReadError classifies an error from the VNC read loop. Returns -// true if the caller should exit; false to retry (transient timeout). +// true if the caller should exit and trigger the cleanup path. A read +// timeout counts as a fatal error: in a healthy session the server emits +// empty FramebufferUpdate responses several times per second, so a full +// idleReadDeadline of silence means the peer is dead (process gone, +// machine off, network partition) and the in-browser TCP stack will +// never surface that on its own. func (p *VNCProxy) handleConnReadError(conn *vncConnection, err error) bool { if conn.ctx.Err() != nil { return true } if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { - // Read timeout: connection might be stale. The next iteration will - // fail too and trigger the close path. - return false - } - if err != io.EOF { + log.Debugf("VNC read deadline expired; treating peer as dead") + } else if err != io.EOF { log.Debugf("read from VNC connection: %v", err) } - // Close the WebSocket to notify noVNC, and cancel the local context so - // cleanupConnection isn't left waiting on the JS close callback that - // may never fire on hard errors. if conn.wsHandlers.Get("close").Truthy() { - conn.wsHandlers.Call("close", 1006, "VNC connection lost") + conn.wsHandlers.Call("close", wsCodeTransport, "VNC connection lost") } conn.cancel() return true From 5543404188578fe9489ac19018c0b8122a6962e1 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 14:07:26 +0200 Subject: [PATCH 035/151] Cap honored VNC client JPEG quality at 50 --- client/vnc/server/extclipboard.go | 2 +- client/vnc/server/extclipboard_test.go | 2 +- client/vnc/server/rfb.go | 20 ++++++++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/client/vnc/server/extclipboard.go b/client/vnc/server/extclipboard.go index 38234b007b5..ba7dba3862d 100644 --- a/client/vnc/server/extclipboard.go +++ b/client/vnc/server/extclipboard.go @@ -16,7 +16,7 @@ import ( // - UTF-8 text format (legacy is Latin-1). // - Pull-based: a Notify announces "I have new content", the peer fetches // via Request only when it actually needs the data. Saves bandwidth on -// a high-latency relay path versus pushing every change. +// high-latency transports versus pushing every change. // - zlib-compressed payloads. // - Caps negotiation so each side knows the other's per-format max size. // diff --git a/client/vnc/server/extclipboard_test.go b/client/vnc/server/extclipboard_test.go index 43c278bc3c6..70a106af913 100644 --- a/client/vnc/server/extclipboard_test.go +++ b/client/vnc/server/extclipboard_test.go @@ -16,7 +16,7 @@ func TestBuildExtClipCaps(t *testing.T) { require.Len(t, payload, 8, "Caps with one format should be 4 bytes flags + 4 bytes size") flags := binary.BigEndian.Uint32(payload[0:4]) - // noVNC checks individual action bits in our Caps to decide whether to + // Clients check individual action bits in our Caps to decide whether to // auto-Request on Notify, so all supported actions must be advertised. assert.NotZero(t, flags&extClipActionCaps, "Caps action bit must be set") assert.NotZero(t, flags&extClipActionRequest, "Request action bit must be set") diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 93e71d84f6d..bf25d763264 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -472,8 +472,11 @@ func newTightStateWithLevels(qualityLevel, compressLevel int) *tightState { } // jpegQualityForLevel maps a 0..9 client preference to a JPEG quality value. -// Returns 0 when no preference is set (-1), letting the encoder fall back to -// the area-based tiers. +// Returns 0 when no preference is set (-1), letting the encoder fall back +// to the area-based tiers. The output is capped at jpegQualityClientCap +// so a client asking for the highest quality does not push per-frame JPEG +// byte counts into a regime that overwhelms bandwidth-constrained +// transports. Within the cap the mapping is still linear. func jpegQualityForLevel(level int) int { if level < 0 { return 0 @@ -481,10 +484,19 @@ func jpegQualityForLevel(level int) int { if level > 9 { level = 9 } - // 0 -> 30, 9 -> 93. Linear so adjacent steps are perceptually similar. - return 30 + level*7 + q := 30 + level*7 + if q > jpegQualityClientCap { + q = jpegQualityClientCap + } + return q } +// jpegQualityClientCap upper-bounds the JPEG quality we honour from the +// client's QualityLevel pseudo-encoding. 50 keeps full-screen JPEGs in +// the same byte range as the area-tiered defaults used when the client +// does not express a preference. +const jpegQualityClientCap = 50 + // zlibLevelFor maps a 0..9 client preference to a zlib compression level. // Level 0 ("no compression") would emit larger output than input on most // rects, so we floor to BestSpeed (1). -1 (no preference) also picks From bc407527f4a9768aff8ba5a127c704e8366dda82 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 18 May 2026 14:48:54 +0200 Subject: [PATCH 036/151] Register VNC netstack service only when netstack is active --- client/internal/engine_vnc.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index fa62f8396a9..44bb3ea2ef5 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -148,11 +148,13 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { e.vncSrv = srv - if registrar, ok := e.firewall.(interface { - RegisterNetstackService(protocol nftypes.Protocol, port uint16) - }); ok { - registrar.RegisterNetstackService(nftypes.TCP, vncInternalPort) - log.Debugf("registered VNC service for TCP:%d", vncInternalPort) + if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + if registrar, ok := e.firewall.(interface { + RegisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.RegisterNetstackService(nftypes.TCP, vncInternalPort) + log.Debugf("registered VNC service with netstack for TCP:%d", vncInternalPort) + } } if err := e.setupVNCPortRedirection(); err != nil { @@ -244,10 +246,12 @@ func (e *Engine) stopVNCServer() error { log.Warnf("cleanup VNC port redirection: %v", err) } - if registrar, ok := e.firewall.(interface { - UnregisterNetstackService(protocol nftypes.Protocol, port uint16) - }); ok { - registrar.UnregisterNetstackService(nftypes.TCP, vncInternalPort) + if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + if registrar, ok := e.firewall.(interface { + UnregisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.UnregisterNetstackService(nftypes.TCP, vncInternalPort) + } } log.Info("stopping VNC server") From 6bb66e0fad31341394da9dc9a61cf58c90fea6b1 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 16 May 2026 22:51:48 +0900 Subject: [PATCH 037/151] [management] Avoid peer IP reallocation when account settings update preserves the network range (#6173) --- management/server/account.go | 37 +++++++++++-- management/server/account_test.go | 90 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 77a46a069aa..e7b4acaac9e 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -291,10 +291,15 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco return nil, status.NewPermissionDeniedError() } + // Canonicalize the incoming range so a caller-supplied prefix with host bits + // (e.g. 100.64.1.1/16) compares equal to the masked form stored on network.Net. + newSettings.NetworkRange = newSettings.NetworkRange.Masked() + var oldSettings *types.Settings var updateAccountPeers bool var groupChangesAffectPeers bool var reloadReverseProxy bool + var effectiveOldNetworkRange netip.Prefix err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { var groupsUpdated bool @@ -308,6 +313,16 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco return err } + // No lock: the transaction already holds Settings(Update), and network.Net is + // only mutated by reallocateAccountPeerIPs, which is reachable only through + // this same code path. A Share lock here would extend an unnecessary row lock + // and complicate ordering against updatePeerIPv6InTransaction. + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("get account network: %w", err) + } + effectiveOldNetworkRange = prefixFromIPNet(network.Net) + if oldSettings.Extra != nil && newSettings.Extra != nil && oldSettings.Extra.PeerApprovalEnabled && !newSettings.Extra.PeerApprovalEnabled { approvedCount, err := transaction.ApproveAccountPeers(ctx, accountID) @@ -321,7 +336,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } } - if oldSettings.NetworkRange != newSettings.NetworkRange { + if newSettings.NetworkRange.IsValid() && newSettings.NetworkRange != effectiveOldNetworkRange { if err = am.reallocateAccountPeerIPs(ctx, transaction, accountID, newSettings.NetworkRange); err != nil { return err } @@ -396,9 +411,9 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountDNSDomainUpdated, eventMeta) } - if oldSettings.NetworkRange != newSettings.NetworkRange { + if newSettings.NetworkRange.IsValid() && newSettings.NetworkRange != effectiveOldNetworkRange { eventMeta := map[string]any{ - "old_network_range": oldSettings.NetworkRange.String(), + "old_network_range": effectiveOldNetworkRange.String(), "new_network_range": newSettings.NetworkRange.String(), } am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta) @@ -443,6 +458,22 @@ func ipv6SettingsChanged(old, updated *types.Settings) bool { return !slices.Equal(oldGroups, newGroups) } +// prefixFromIPNet returns the overlay prefix actually allocated on the account +// network, or an invalid prefix if none is set. Settings.NetworkRange is a +// user-facing override that is empty on legacy accounts, so the effective +// range must be read from network.Net to compare against an incoming update. +func prefixFromIPNet(ipNet net.IPNet) netip.Prefix { + if ipNet.IP == nil { + return netip.Prefix{} + } + addr, ok := netip.AddrFromSlice(ipNet.IP) + if !ok { + return netip.Prefix{} + } + ones, _ := ipNet.Mask.Size() + return netip.PrefixFrom(addr.Unmap(), ones) +} + func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, transaction store.Store, newSettings, oldSettings *types.Settings, userID, accountID string) error { halfYearLimit := 180 * 24 * time.Hour if newSettings.PeerLoginExpiration > halfYearLimit { diff --git a/management/server/account_test.go b/management/server/account_test.go index 65b27df49b3..60720faa662 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3970,6 +3970,96 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangeChange(t *testi } } +// TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved guards against +// peer IP reallocation when a settings update carries the network range that is already +// in use. Legacy accounts have Settings.NetworkRange unset in the DB while network.Net +// holds the actual allocated overlay; the dashboard backfills the GET response from +// network.Net and echoes the value back on PUT, so the diff must be against the +// effective range to avoid renumbering every peer on an unrelated settings change. +func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *testing.T) { + manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + + settings, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, account.Id) + require.NoError(t, err) + require.False(t, settings.NetworkRange.IsValid(), "precondition: new accounts leave Settings.NetworkRange unset") + + network, err := manager.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, account.Id) + require.NoError(t, err) + require.NotNil(t, network.Net.IP, "precondition: network.Net should be allocated") + addr, ok := netip.AddrFromSlice(network.Net.IP) + require.True(t, ok) + ones, _ := network.Net.Mask.Size() + effective := netip.PrefixFrom(addr.Unmap(), ones) + require.True(t, effective.IsValid()) + + before := map[string]netip.Addr{peer1.ID: peer1.IP, peer2.ID: peer2.IP, peer3.ID: peer3.IP} + + // Round-trip the effective range as if the dashboard echoed back the GET-backfilled value. + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + NetworkRange: effective, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + require.Len(t, peers, len(before)) + for _, p := range peers { + assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when range matches effective", p.ID) + } + + // Carrying the same range with host bits set must also be a no-op once canonicalized. + hostBitsForm := netip.PrefixFrom(peer1.IP, ones) + require.NotEqual(t, effective, hostBitsForm, "precondition: host-bit form should differ before masking") + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + NetworkRange: hostBitsForm, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change for host-bit-set equivalent range", p.ID) + } + + // Omitting NetworkRange (invalid prefix) must also be a no-op. + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when NetworkRange omitted", p.ID) + } + + // Sanity: an actually different range still triggers reallocation. + newRange := netip.MustParsePrefix("100.99.0.0/16") + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + NetworkRange: newRange, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.True(t, newRange.Contains(p.IP), "peer %s should be in new range %s, got %s", p.ID, newRange, p.IP) + assert.NotEqual(t, before[p.ID], p.IP, "peer %s IP should change on real range update", p.ID) + } +} + func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testing.T) { manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t) ctx := context.Background() From d3f3e08035b627b7c93d81b059a273bddc439258 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sat, 16 May 2026 16:29:01 +0200 Subject: [PATCH 038/151] Avoid context cancellation in `cancelPeerRoutines` (#6175) When closing go routines and handling peer disconnect, we should avoid canceling the flow due to parent gRPC context cancellation. This change triggers disconnection handling with a context that is not bound to the parent gRPC cancellation. --- management/internals/shared/grpc/server.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index b2e55649984..d932abada69 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -522,10 +522,11 @@ func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Even } func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) { - unlock := s.acquirePeerLockByUID(ctx, peer.Key) + uncanceledCTX := context.WithoutCancel(ctx) + unlock := s.acquirePeerLockByUID(uncanceledCTX, peer.Key) defer unlock() - s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, streamStartTime) + s.cancelPeerRoutinesWithoutLock(uncanceledCTX, accountID, peer, streamStartTime) } func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) { From d927ef468a73a15c734987b9cd5478f2a5b12738 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 16 May 2026 23:52:57 +0900 Subject: [PATCH 039/151] Clean up legacy 32-bit and HKCU registry entries on Windows install (#6176) --- client/installer.nsis | 23 ++++++++++++++++++----- client/netbird.wxs | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/client/installer.nsis b/client/installer.nsis index 63bff1c5b72..3e057df10e5 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -260,15 +260,23 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}" WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}" -; Create autostart registry entry based on checkbox +; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view +; or HKCU by legacy installers. +DetailPrint "Cleaning legacy 32-bit / HKCU entries..." +DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" +SetRegView 32 +DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" +DeleteRegKey HKLM "${REG_APP_PATH}" +DeleteRegKey HKLM "${UI_REG_APP_PATH}" +DeleteRegKey HKLM "${UNINSTALL_PATH}" +SetRegView 64 + DetailPrint "Autostart enabled: $AutostartEnabled" ${If} $AutostartEnabled == "1" WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"' DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe" ${Else} DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" - ; Legacy: pre-HKLM installs wrote to HKCU; clean that up too. - DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" DetailPrint "Autostart not enabled by user" ${EndIf} @@ -299,11 +307,16 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall' DetailPrint "Terminating Netbird UI process..." ExecWait `taskkill /im ${UI_APP_EXE}.exe /f` -; Remove autostart registry entry +; Remove autostart entries from every view a previous installer may have used. DetailPrint "Removing autostart registry entry if exists..." DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" -; Legacy: pre-HKLM installs wrote to HKCU; clean that up too. DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" +SetRegView 32 +DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" +DeleteRegKey HKLM "${REG_APP_PATH}" +DeleteRegKey HKLM "${UI_REG_APP_PATH}" +DeleteRegKey HKLM "${UNINSTALL_PATH}" +SetRegView 64 ; Handle data deletion based on checkbox DetailPrint "Checking if user requested data deletion..." diff --git a/client/netbird.wxs b/client/netbird.wxs index 6f18b63b53e..96814ce5254 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -64,6 +64,13 @@ + + + + + @@ -76,10 +83,28 @@ + + + + + + + + + + + From 32a5a061b835438419574b2df0aa1d594ea11690 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 18 May 2026 12:57:59 +0200 Subject: [PATCH 040/151] [management] fix: device redirect uri wasn't registered (#6191) * fix: device redirect uri wasn't registered * fix lint --- management/server/idp/embedded.go | 27 ++++++++++++++++++++----- management/server/idp/embedded_test.go | 28 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index a1852a8bcbf..821e6ff5577 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "net/http" + "net/url" "os" + "path" "strings" "github.com/dexidp/dex/storage" @@ -138,10 +140,13 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { return nil, fmt.Errorf("invalid IdP storage config: %w", err) } - // Build CLI redirect URIs including the device callback (both relative and absolute) + // Build CLI redirect URIs including the device callback. Dex uses the issuer-relative + // path (for example, /oauth2/device/callback) when completing the device flow, so + // include it explicitly in addition to the legacy bare path and absolute URL. cliRedirectURIs := c.CLIRedirectURIs cliRedirectURIs = append(cliRedirectURIs, "/device/callback") - cliRedirectURIs = append(cliRedirectURIs, c.Issuer+"/device/callback") + cliRedirectURIs = append(cliRedirectURIs, issuerRelativeDeviceCallback(c.Issuer)) + cliRedirectURIs = append(cliRedirectURIs, strings.TrimSuffix(c.Issuer, "/")+"/device/callback") // Build dashboard redirect URIs including the OAuth callback for proxy authentication dashboardRedirectURIs := c.DashboardRedirectURIs @@ -154,6 +159,10 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { // MGMT api and the dashboard, adding baseURL means less configuration for the instance admin dashboardPostLogoutRedirectURIs = append(dashboardPostLogoutRedirectURIs, baseURL) + redirectURIs := make([]string, 0) + redirectURIs = append(redirectURIs, cliRedirectURIs...) + redirectURIs = append(redirectURIs, dashboardRedirectURIs...) + cfg := &dex.YAMLConfig{ Issuer: c.Issuer, Storage: dex.Storage{ @@ -179,14 +188,14 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { ID: staticClientDashboard, Name: "NetBird Dashboard", Public: true, - RedirectURIs: dashboardRedirectURIs, + RedirectURIs: redirectURIs, PostLogoutRedirectURIs: sanitizePostLogoutRedirectURIs(dashboardPostLogoutRedirectURIs), }, { ID: staticClientCLI, Name: "NetBird CLI", Public: true, - RedirectURIs: cliRedirectURIs, + RedirectURIs: redirectURIs, }, }, StaticConnectors: c.StaticConnectors, @@ -217,6 +226,14 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { return cfg, nil } +func issuerRelativeDeviceCallback(issuer string) string { + u, err := url.Parse(issuer) + if err != nil || u.Path == "" { + return "/device/callback" + } + return path.Join(u.Path, "/device/callback") +} + // Due to how the frontend generates the logout, sometimes it appends a trailing slash // and because Dex only allows exact matches, we need to make sure we always have both // versions of each provided uri @@ -299,7 +316,7 @@ func resolveSessionCookieEncryptionKey(configuredKey string) (string, error) { } } - return "", fmt.Errorf("invalid embedded IdP session cookie encryption key: %s (or sessionCookieEncryptionKey) must be 16, 24, or 32 bytes as a raw string or base64-encoded to one of those lengths; got %d raw bytes", sessionCookieEncryptionKeyEnv, len([]byte(key))) + return "", fmt.Errorf("invalid embedded IdP session cookie encryption key:%s (or sessionCookieEncryptionKey) must be 16, 24, or 32 bytes as a raw string or base64-encoded to one of those lengths; got %d raw bytes", sessionCookieEncryptionKeyEnv, len([]byte(key))) } func validSessionCookieEncryptionKeyLength(length int) bool { diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go index 09dc676142d..91cd27aee73 100644 --- a/management/server/idp/embedded_test.go +++ b/management/server/idp/embedded_test.go @@ -314,6 +314,34 @@ func TestEmbeddedIdPManager_UpdateUserPassword(t *testing.T) { }) } +func TestEmbeddedIdPConfig_ToYAMLConfig_IncludesDeviceCallbackRedirectURI(t *testing.T) { + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "https://example.com/oauth2", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(t.TempDir(), "dex.db"), + }, + }, + } + + yamlConfig, err := config.ToYAMLConfig() + require.NoError(t, err) + + var cliRedirectURIs []string + for _, client := range yamlConfig.StaticClients { + if client.ID == staticClientCLI { + cliRedirectURIs = client.RedirectURIs + break + } + } + require.NotEmpty(t, cliRedirectURIs) + assert.Contains(t, cliRedirectURIs, "/device/callback") + assert.Contains(t, cliRedirectURIs, "/oauth2/device/callback") + assert.Contains(t, cliRedirectURIs, "https://example.com/oauth2/device/callback") +} + func TestEmbeddedIdPConfig_ToYAMLConfig_SessionCookieEncryptionKey(t *testing.T) { t.Setenv(sessionCookieEncryptionKeyEnv, "") From 97bc1eebde9ccc25b2c198cbff109e0bcb13c580 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 18 May 2026 20:25:12 +0200 Subject: [PATCH 041/151] [management] Fence peer status updates with a session token (#6193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [management] Fence peer status updates with a session token The connect/disconnect path used a best-effort LastSeen-after-streamStart comparison to decide whether a status update should land. Under contention — a re-sync arriving while the previous stream's disconnect was still in flight, or two management replicas seeing the same peer at once — the check was a read-then-decide-then-write window: any UPDATE in between caused the wrong row to be written. The Go-side time.Now() that fed the comparison also drifted under lock contention, since it was captured seconds before the write actually committed. Replace it with an integer-nanosecond fencing token stored alongside the status. Every gRPC sync stream uses its open time (UnixNano) as its token. Connects only land when the incoming token is strictly greater than the stored one; disconnects only land when the incoming token equals the stored one (i.e. we're the stream that owns the current session). Both are single optimistic-locked UPDATEs — no read-then-write, no transaction wrapper. LastSeen is now written by the database itself (CURRENT_TIMESTAMP). The caller never supplies it, so the value always reflects the real moment of the UPDATE rather than the moment the caller queued the work — which was already off by minutes under heavy lock contention. Side effects (geo lookup, peer-login-expiration scheduling, network-map fan-out) are explicitly documented as running after the fence UPDATE commits, never inside it. Geo also skips the update when realIP equals the stored ConnectionIP, dropping a redundant SavePeerLocation call on same-IP reconnects. Tests cover the three semantic cases (matched disconnect lands, stale disconnect dropped, stale connect dropped) plus a 16-goroutine race test that asserts the highest token always wins. * [management] Add SessionStartedAt to peer status updates Stored `SessionStartedAt` for fencing token propagation across goroutines and updated database queries/functions to handle the new field. Removed outdated geolocation handling logic and adjusted tests for concurrency safety. * Rename `peer_status_required_approval` to `peer_status_requires_approval` in SQL store fields --- management/server/account.go | 29 ++--- management/server/account/manager.go | 3 +- management/server/account/manager_mock.go | 22 +++- management/server/account_test.go | 115 ++++++++++++++--- management/server/mock_server/account_mock.go | 24 +++- management/server/peer.go | 121 +++++++++--------- management/server/peer/peer.go | 19 ++- management/server/store/sql_store.go | 84 +++++++++++- management/server/store/store.go | 15 +++ management/server/store/store_mock.go | 30 +++++ 10 files changed, 349 insertions(+), 113 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index e7b4acaac9e..8e4e595f0e2 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1868,35 +1868,32 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu return domainCategory == types.PrivateCategory || userAuth.DomainCategory != types.PrivateCategory || domain != userAuth.Domain } +// SyncAndMarkPeer is the per-Sync entry point: it refreshes the peer's +// network map and then marks the peer connected with a session token +// derived from syncTime (the moment the gRPC stream opened). Any +// concurrent stream that started earlier loses the optimistic-lock race +// in MarkPeerConnected and bails without writing. func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta}, accountID) if err != nil { return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err) } - err = am.MarkPeerConnected(ctx, peerPubKey, true, realIP, accountID, syncTime) - if err != nil { + if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano()); err != nil { log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err) } return peer, netMap, postureChecks, dnsfwdPort, nil } +// OnPeerDisconnected is invoked when a sync stream ends. It marks the +// peer disconnected only when the stored SessionStartedAt matches the +// nanosecond token derived from streamStartTime — i.e. only when this +// is the stream that currently owns the peer's session. A mismatch +// means a newer stream has already replaced us, so the disconnect is +// dropped. func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error { - peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) - if err != nil { - log.WithContext(ctx).Warnf("failed to get peer %s for disconnect check: %v", peerPubKey, err) - return nil - } - - if peer.Status.LastSeen.After(streamStartTime) { - log.WithContext(ctx).Tracef("peer %s has newer activity (lastSeen=%s > streamStart=%s), skipping disconnect", - peerPubKey, peer.Status.LastSeen.Format(time.RFC3339), streamStartTime.Format(time.RFC3339)) - return nil - } - - err = am.MarkPeerConnected(ctx, peerPubKey, false, nil, accountID, time.Now().UTC()) - if err != nil { + if err := am.MarkPeerDisconnected(ctx, peerPubKey, accountID, streamStartTime.UnixNano()); err != nil { log.WithContext(ctx).Warnf("failed marking peer as disconnected %s %v", peerPubKey, err) } return nil diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 71af0645c70..ae3de8d79c0 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -61,7 +61,8 @@ type Manager interface { GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error + MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error + MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 7ffc41d7331..0486e63ec84 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -1305,17 +1305,31 @@ func (mr *MockManagerMockRecorder) LoginPeer(ctx, login interface{}) *gomock.Cal } // MarkPeerConnected mocks base method. -func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error { +func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, connected, realIP, accountID, syncTime) + ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt) ret0, _ := ret[0].(error) return ret0 } // MarkPeerConnected indicates an expected call of MarkPeerConnected. -func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, connected, realIP, accountID, syncTime interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, connected, realIP, accountID, syncTime) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt) +} + +// MarkPeerDisconnected mocks base method. +func (m *MockManager) MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerDisconnected", ctx, peerKey, accountID, sessionStartedAt) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPeerDisconnected indicates an expected call of MarkPeerDisconnected. +func (mr *MockManagerMockRecorder) MarkPeerDisconnected(ctx, peerKey, accountID, sessionStartedAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnected", reflect.TypeOf((*MockManager)(nil).MarkPeerDisconnected), ctx, peerKey, accountID, sessionStartedAt) } // OnPeerDisconnected mocks base method. diff --git a/management/server/account_test.go b/management/server/account_test.go index 60720faa662..ba621030ce1 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -1813,7 +1813,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) { accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC()) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano()) require.NoError(t, err, "unable to mark peer connected") _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ @@ -1884,7 +1884,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. require.NoError(t, err, "unable to get the account") // when we mark peer as connected, the peer login expiration routine should trigger - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC()) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano()) require.NoError(t, err, "unable to mark peer connected") failed := waitTimeout(wg, time.Second) @@ -1910,15 +1910,16 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { }, false) require.NoError(t, err, "unable to add peer") - t.Run("disconnect peer when streamStartTime is after LastSeen", func(t *testing.T) { - err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, time.Now().UTC()) + t.Run("disconnect peer when session token matches", func(t *testing.T) { + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano()) require.NoError(t, err, "unable to mark peer connected") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) require.NoError(t, err, "unable to get peer") require.True(t, peer.Status.Connected, "peer should be connected") - - streamStartTime := time.Now().UTC() + require.Equal(t, streamStartTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should equal the token we passed in") err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, streamStartTime) require.NoError(t, err) @@ -1926,49 +1927,127 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) require.NoError(t, err) require.False(t, peer.Status.Connected, "peer should be disconnected") + require.Equal(t, int64(0), peer.Status.SessionStartedAt, "SessionStartedAt should be reset to 0") }) - t.Run("skip disconnect when LastSeen is after streamStartTime (zombie stream protection)", func(t *testing.T) { - err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, time.Now().UTC()) + t.Run("skip disconnect when stored session is newer (zombie stream protection)", func(t *testing.T) { + // Newer stream wins on connect (sets SessionStartedAt = now ns). + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano()) require.NoError(t, err, "unable to mark peer connected") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) require.NoError(t, err) require.True(t, peer.Status.Connected, "peer should be connected") - streamStartTime := peer.Status.LastSeen.Add(-1 * time.Hour) + // Older stream tries to mark disconnect with its own (older) session token — + // fencing kicks in and the write is dropped. + staleStreamStartTime := streamStartTime.Add(-1 * time.Hour) - err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, streamStartTime) + err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, staleStreamStartTime) require.NoError(t, err) peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) require.NoError(t, err) require.True(t, peer.Status.Connected, - "peer should remain connected because LastSeen > streamStartTime (zombie stream protection)") + "peer should remain connected because the stored session is newer than the disconnect token") + require.Equal(t, streamStartTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should still hold the winning stream's token") }) - t.Run("skip stale connect when peer already has newer LastSeen (blocked goroutine protection)", func(t *testing.T) { + t.Run("skip stale connect when stored session is newer (blocked goroutine protection)", func(t *testing.T) { node2SyncTime := time.Now().UTC() - err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, node2SyncTime) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano()) require.NoError(t, err, "node 2 should connect peer") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) require.NoError(t, err) require.True(t, peer.Status.Connected, "peer should be connected") - require.Equal(t, node2SyncTime.Unix(), peer.Status.LastSeen.Unix(), "LastSeen should be node2SyncTime") + require.Equal(t, node2SyncTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should equal node2SyncTime token") node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute) - err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, node1StaleSyncTime) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano()) require.NoError(t, err, "stale connect should not return error") peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) require.NoError(t, err) require.True(t, peer.Status.Connected, "peer should still be connected") - require.Equal(t, node2SyncTime.Unix(), peer.Status.LastSeen.Unix(), - "LastSeen should NOT be overwritten by stale syncTime from blocked goroutine") + require.Equal(t, node2SyncTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should NOT be overwritten by stale token from blocked goroutine") }) } +// TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace exercises the +// fencing protocol under contention: many goroutines race to mark the +// same peer connected with distinct session tokens at the same time. +// The contract is that the highest token always wins and is what remains +// in the store, regardless of execution order. +func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to get account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "race-peer"}, + }, false) + require.NoError(t, err, "unable to add peer") + + const workers = 16 + base := time.Now().UTC().UnixNano() + tokens := make([]int64, workers) + for i := range tokens { + // Spread tokens by 1ms so the comparison is unambiguous; the + // largest is index workers-1. + tokens[i] = base + int64(i)*int64(time.Millisecond) + } + expected := tokens[workers-1] + + var ready sync.WaitGroup + ready.Add(workers) + var start sync.WaitGroup + start.Add(1) + var done sync.WaitGroup + done.Add(workers) + + // require.* calls t.FailNow which is documented as unsafe from + // non-test goroutines (it calls runtime.Goexit on the wrong stack and + // races with the WaitGroup). Collect errors here and assert from the + // main goroutine after done.Wait(). + errs := make(chan error, workers) + + for i := 0; i < workers; i++ { + token := tokens[i] + go func() { + defer done.Done() + ready.Done() + start.Wait() + errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token) + }() + } + + ready.Wait() + start.Done() + done.Wait() + close(errs) + for err := range errs { + require.NoError(t, err, "MarkPeerConnected must not error under contention") + } + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should be connected after the race") + require.Equal(t, expected, peer.Status.SessionStartedAt, + "the largest token must win regardless of execution order") +} + func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") @@ -1991,7 +2070,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test account, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC()) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano()) require.NoError(t, err, "unable to mark peer connected") wg := &sync.WaitGroup{} diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 08091d4b7aa..aba40818430 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -38,7 +38,8 @@ type MockAccountManager struct { GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnectedFunc func(ctx context.Context, peerKey string, connected bool, realIP net.IP, syncTime time.Time) error + MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error + MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error) @@ -227,7 +228,14 @@ func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID str return nil, nil, nil, 0, status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } -func (am *MockAccountManager) OnPeerDisconnected(_ context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error { +func (am *MockAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error { + // Mirror DefaultAccountManager.OnPeerDisconnected: drive the fencing + // hook so tests that inject MarkPeerDisconnectedFunc actually observe + // disconnect events. Falls through to nil when no hook is set, which + // is the original behaviour. + if am.MarkPeerDisconnectedFunc != nil { + return am.MarkPeerDisconnectedFunc(ctx, peerPubKey, accountID, streamStartTime.UnixNano()) + } return nil } @@ -328,13 +336,21 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth } // MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface -func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error { +func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error { if am.MarkPeerConnectedFunc != nil { - return am.MarkPeerConnectedFunc(ctx, peerKey, connected, realIP, syncTime) + return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt) } return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } +// MarkPeerDisconnected mock implementation of MarkPeerDisconnected from server.AccountManager interface +func (am *MockAccountManager) MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error { + if am.MarkPeerDisconnectedFunc != nil { + return am.MarkPeerDisconnectedFunc(ctx, peerKey, accountID, sessionStartedAt) + } + return status.Errorf(codes.Unimplemented, "method MarkPeerDisconnected is not implemented") +} + // DeleteAccount mock implementation of DeleteAccount from server.AccountManager interface func (am *MockAccountManager) DeleteAccount(ctx context.Context, accountID, userID string) error { if am.DeleteAccountFunc != nil { diff --git a/management/server/peer.go b/management/server/peer.go index c3b130ba28f..4790a5aab42 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -16,7 +16,6 @@ import ( "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/idp" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -63,56 +62,51 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) } -// MarkPeerConnected marks peer as connected (true) or disconnected (false) -// syncTime is used as the LastSeen timestamp and for stale request detection -func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error { - var peer *nbpeer.Peer - var settings *types.Settings - var expired bool - var err error - var skipped bool - - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - peer, err = transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, peerPubKey) - if err != nil { - return err - } - - if connected && !syncTime.After(peer.Status.LastSeen) { - log.WithContext(ctx).Tracef("peer %s has newer activity (lastSeen=%s >= syncTime=%s), skipping connect", - peer.ID, peer.Status.LastSeen.Format(time.RFC3339), syncTime.Format(time.RFC3339)) - skipped = true - return nil - } - - expired, err = updatePeerStatusAndLocation(ctx, am.geo, transaction, peer, connected, realIP, accountID, syncTime) +// MarkPeerConnected marks a peer as connected with optimistic-locked +// fencing on PeerStatus.SessionStartedAt. The sessionStartedAt argument +// is the start time of the gRPC sync stream that owns this update, +// expressed as Unix nanoseconds — only the call whose token is greater +// than what's stored wins. LastSeen is written by the database itself; +// we never pass it down. +// +// Disconnects use MarkPeerDisconnected and require the session to match +// exactly; see PeerStatus.SessionStartedAt for the protocol. +func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64) error { + peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) + if err != nil { return err - }) - if skipped { - return nil } + + updated, err := am.Store.MarkPeerConnectedIfNewerSession(ctx, accountID, peer.ID, sessionStartedAt) if err != nil { return err } + if !updated { + log.WithContext(ctx).Tracef("peer %s already has a newer session in store, skipping connect", peer.ID) + return nil + } + + if am.geo != nil && realIP != nil { + am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP) + } + + expired := peer.Status != nil && peer.Status.LoginExpired if peer.AddedWithSSOLogin() { - settings, err = am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { return err } - if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled { am.schedulePeerLoginExpiration(ctx, accountID) } - if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled { am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) } } if expired { - err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) - if err != nil { + if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil { return fmt.Errorf("notify network map controller of peer update: %w", err) } } @@ -120,41 +114,46 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK return nil } -func updatePeerStatusAndLocation(ctx context.Context, geo geolocation.Geolocation, transaction store.Store, peer *nbpeer.Peer, connected bool, realIP net.IP, accountID string, syncTime time.Time) (bool, error) { - oldStatus := peer.Status.Copy() - newStatus := oldStatus - newStatus.LastSeen = syncTime - newStatus.Connected = connected - // whenever peer got connected that means that it logged in successfully - if newStatus.Connected { - newStatus.LoginExpired = false +// MarkPeerDisconnected marks a peer as disconnected, but only when the +// stored session token matches the one passed in. A mismatch means a +// newer stream has already taken ownership of the peer — disconnects from +// the older stream are ignored. LastSeen is written by the database. +func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerPubKey string, accountID string, sessionStartedAt int64) error { + peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) + if err != nil { + return err } - peer.Status = newStatus - if geo != nil && realIP != nil { - location, err := geo.Lookup(realIP) - if err != nil { - log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) - } else { - peer.Location.ConnectionIP = realIP - peer.Location.CountryCode = location.Country.ISOCode - peer.Location.CityName = location.City.Names.En - peer.Location.GeoNameID = location.City.GeonameID - err = transaction.SavePeerLocation(ctx, accountID, peer) - if err != nil { - log.WithContext(ctx).Warnf("could not store location for peer %s: %s", peer.ID, err) - } - } + updated, err := am.Store.MarkPeerDisconnectedIfSameSession(ctx, accountID, peer.ID, sessionStartedAt) + if err != nil { + return err } + if !updated { + log.WithContext(ctx).Tracef("peer %s session token mismatch on disconnect (token=%d), skipping", + peer.ID, sessionStartedAt) + } + return nil +} - log.WithContext(ctx).Debugf("saving peer status for peer %s is connected: %t", peer.ID, connected) - - err := transaction.SavePeerStatus(ctx, accountID, peer.ID, *newStatus) +// updatePeerLocationIfChanged refreshes the geolocation on a separate +// row update, only when the connection IP actually changed. Geo lookups +// are expensive so we skip same-IP reconnects. +func (am *DefaultAccountManager) updatePeerLocationIfChanged(ctx context.Context, accountID string, peer *nbpeer.Peer, realIP net.IP) { + if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) { + return + } + location, err := am.geo.Lookup(realIP) if err != nil { - return false, err + log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) + return + } + peer.Location.ConnectionIP = realIP + peer.Location.CountryCode = location.Country.ISOCode + peer.Location.CityName = location.City.Names.En + peer.Location.GeoNameID = location.City.GeonameID + if err := am.Store.SavePeerLocation(ctx, accountID, peer); err != nil { + log.WithContext(ctx).Warnf("could not store location for peer %s: %s", peer.ID, err) } - - return oldStatus.LoginExpired, nil } // UpdatePeer updates peer. Only Peer.Name, Peer.SSHEnabled, Peer.LoginExpirationEnabled and Peer.InactivityExpirationEnabled can be updated. diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 70e94bb0879..c73721af75c 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -74,8 +74,19 @@ type ProxyMeta struct { } type PeerStatus struct { //nolint:revive - // LastSeen is the last time peer was connected to the management service + // LastSeen is the last time the peer status was updated (i.e. the last + // time we observed the peer being alive on a sync stream). Written by + // the database (CURRENT_TIMESTAMP) — callers do not supply it. LastSeen time.Time + // SessionStartedAt records when the currently-active sync stream began, + // stored as Unix nanoseconds. It acts as the optimistic-locking token + // for status updates: a stream is only allowed to mutate the peer's + // status when its own token strictly exceeds the stored token (when connecting) + // or matches it exactly (for disconnects). Zero means "no + // active session". Integer nanoseconds are used so equality is + // precision-safe across drivers, and so the predicates compose to a + // single bigint comparison. + SessionStartedAt int64 // Connected indicates whether peer is connected to the management service or not Connected bool // LoginExpired @@ -377,10 +388,14 @@ func (p *Peer) EventMeta(dnsDomain string) map[string]any { return meta } -// Copy PeerStatus +// Copy PeerStatus. SessionStartedAt must be propagated so clone-based +// callers (Peer.Copy, MarkLoginExpired, UpdateLastLogin) don't silently +// reset the fencing token to zero — that would let any subsequent +// SavePeerStatus write reopen the optimistic-lock window. func (p *PeerStatus) Copy() *PeerStatus { return &PeerStatus{ LastSeen: p.LastSeen, + SessionStartedAt: p.SessionStartedAt, Connected: p.Connected, LoginExpired: p.LoginExpired, RequiresApproval: p.RequiresApproval, diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 893ee2168e4..8cf37de56a0 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -498,8 +498,9 @@ func (s *SqlStore) SavePeerStatus(ctx context.Context, accountID, peerID string, peerCopy.Status = &peerStatus fieldsToUpdate := []string{ - "peer_status_last_seen", "peer_status_connected", - "peer_status_login_expired", "peer_status_required_approval", + "peer_status_last_seen", "peer_status_session_started_at", + "peer_status_connected", "peer_status_login_expired", + "peer_status_requires_approval", } result := s.db.Model(&nbpeer.Peer{}). Select(fieldsToUpdate). @@ -516,6 +517,69 @@ func (s *SqlStore) SavePeerStatus(ctx context.Context, accountID, peerID string, return nil } +// MarkPeerConnectedIfNewerSession is an atomic optimistic-locked update. +// The peer is marked connected with the given session token only when +// the stored SessionStartedAt is strictly smaller than the incoming +// one — equivalently, when no newer stream has already taken ownership. +// The sentinel zero (set on peer creation or after a disconnect) counts +// as the smallest possible token. This is the write half of the +// fencing protocol described on PeerStatus.SessionStartedAt. +// +// The post-write side effects in the caller — geo lookup, +// schedulePeerLoginExpiration, checkAndSchedulePeerInactivityExpiration, +// OnPeersUpdated — all run AFTER this method returns and are deliberately +// outside the database write so they cannot extend the row-lock window. +// +// LastSeen is set to the database's clock (CURRENT_TIMESTAMP) at the +// moment the row is written. The caller never supplies LastSeen because +// the value would otherwise drift under lock contention — a Go-side +// time.Now() taken before the write can land minutes later than the +// actual UPDATE under load, which previously caused real ordering bugs. +func (s *SqlStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) { + result := s.db.WithContext(ctx). + Model(&nbpeer.Peer{}). + Where(accountAndIDQueryCondition, accountID, peerID). + Where("peer_status_session_started_at < ?", newSessionStartedAt). + Updates(map[string]any{ + "peer_status_connected": true, + "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), + "peer_status_session_started_at": newSessionStartedAt, + "peer_status_login_expired": false, + }) + if result.Error != nil { + return false, status.Errorf(status.Internal, "mark peer connected: %v", result.Error) + } + return result.RowsAffected > 0, nil +} + +// MarkPeerDisconnectedIfSameSession is an atomic optimistic-locked update. +// The peer is marked disconnected only when the stored SessionStartedAt +// matches the incoming token — meaning the stream that owns the current +// session is the one ending. If a newer stream has already replaced the +// session, the update is skipped. LastSeen is set to CURRENT_TIMESTAMP at +// write time; see MarkPeerConnectedIfNewerSession for the rationale. +// +// A zero sessionStartedAt is rejected at the call site; the underlying +// WHERE on equality would otherwise match every never-connected peer. +func (s *SqlStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) { + if sessionStartedAt == 0 { + return false, nil + } + result := s.db.WithContext(ctx). + Model(&nbpeer.Peer{}). + Where(accountAndIDQueryCondition, accountID, peerID). + Where("peer_status_session_started_at = ?", sessionStartedAt). + Updates(map[string]any{ + "peer_status_connected": false, + "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), + "peer_status_session_started_at": int64(0), + }) + if result.Error != nil { + return false, status.Errorf(status.Internal, "mark peer disconnected: %v", result.Error) + } + return result.RowsAffected > 0, nil +} + func (s *SqlStore) SavePeerLocation(ctx context.Context, accountID string, peerWithLocation *nbpeer.Peer) error { // To maintain data integrity, we create a copy of the peer's location to prevent unintended updates to other fields. var peerCopy nbpeer.Peer @@ -1723,9 +1787,10 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, - meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_connected, peer_status_login_expired, - peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, - location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6 FROM peers WHERE account_id = $1` + meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at, + peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, + location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6 + FROM peers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1738,6 +1803,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee lastLogin, createdAt sql.NullTime sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool peerStatusLastSeen sql.NullTime + peerStatusSessionStartedAt sql.NullInt64 peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString @@ -1752,8 +1818,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee &allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform, &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, - &peerStatusLastSeen, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP, - &locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded, &proxyCluster, &ipv6) + &peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired, + &peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID, + &proxyEmbedded, &proxyCluster, &ipv6) if err == nil { if lastLogin.Valid { @@ -1780,6 +1847,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if peerStatusLastSeen.Valid { p.Status.LastSeen = peerStatusLastSeen.Time } + if peerStatusSessionStartedAt.Valid { + p.Status.SessionStartedAt = peerStatusSessionStartedAt.Int64 + } if peerStatusConnected.Valid { p.Status.Connected = peerStatusConnected.Bool } diff --git a/management/server/store/store.go b/management/server/store/store.go index aa601c33fe9..a723c1fc316 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -167,6 +167,21 @@ type Store interface { GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*nbpeer.Peer, error) SavePeer(ctx context.Context, accountID string, peer *nbpeer.Peer) error SavePeerStatus(ctx context.Context, accountID, peerID string, status nbpeer.PeerStatus) error + // MarkPeerConnectedIfNewerSession sets the peer to connected with the + // given session token, but only when the stored SessionStartedAt is + // strictly less than newSessionStartedAt (the sentinel zero counts as + // "older"). LastSeen is recorded by the database at the moment the + // row is updated — never by the caller — so it always reflects the + // real write time even under lock contention. + // Returns true when the update happened, false when this stream lost + // the race against a newer session. + MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) + // MarkPeerDisconnectedIfSameSession sets the peer to disconnected and + // resets SessionStartedAt to zero, but only when the stored + // SessionStartedAt equals the given sessionStartedAt. LastSeen is + // recorded by the database. Returns true when the update happened, + // false when a newer session has taken over. + MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) SavePeerLocation(ctx context.Context, accountID string, peer *nbpeer.Peer) error ApproveAccountPeers(ctx context.Context, accountID string) (int, error) DeletePeer(ctx context.Context, accountID string, peerID string) error diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 9780c521e94..d5162960693 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -2878,6 +2878,36 @@ func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status i return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerStatus", reflect.TypeOf((*MockStore)(nil).SavePeerStatus), ctx, accountID, peerID, status) } +// MarkPeerConnectedIfNewerSession mocks base method. +func (m *MockStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerConnectedIfNewerSession", ctx, accountID, peerID, newSessionStartedAt) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MarkPeerConnectedIfNewerSession indicates an expected call of MarkPeerConnectedIfNewerSession. +func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnectedIfNewerSession", reflect.TypeOf((*MockStore)(nil).MarkPeerConnectedIfNewerSession), ctx, accountID, peerID, newSessionStartedAt) +} + +// MarkPeerDisconnectedIfSameSession mocks base method. +func (m *MockStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerDisconnectedIfSameSession", ctx, accountID, peerID, sessionStartedAt) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MarkPeerDisconnectedIfSameSession indicates an expected call of MarkPeerDisconnectedIfSameSession. +func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnectedIfSameSession", reflect.TypeOf((*MockStore)(nil).MarkPeerDisconnectedIfSameSession), ctx, accountID, peerID, sessionStartedAt) +} + // SavePolicy mocks base method. func (m *MockStore) SavePolicy(ctx context.Context, policy *types2.Policy) error { m.ctrl.T.Helper() From 8e2505b59cdb002db67b73ff515a0b11cb485021 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 18 May 2026 22:55:19 +0200 Subject: [PATCH 042/151] [management] Add metrics for peer status updates and ephemeral cleanup (#6196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [management] Add metrics for peer status updates and ephemeral cleanup The session-fenced MarkPeerConnected / MarkPeerDisconnected path and the ephemeral peer cleanup loop both run silently today: when fencing rejects a stale stream, when a cleanup tick deletes peers, or when a batch delete fails, we have no operational signal beyond log lines. Add OpenTelemetry counters and a histogram so the same SLO-style dashboards that already exist for the network-map controller can cover peer connect/disconnect and ephemeral cleanup too. All new attributes are bounded enums: operation in {connect,disconnect} and outcome in {applied,stale,error,peer_not_found}. No account, peer, or user ID is ever written as a metric label — total cardinality is fixed at compile time (8 counter series, 2 histogram series, 4 unlabeled ephemeral series). Metric methods are nil-receiver safe so test composition that doesn't wire telemetry (the bulk of the existing tests) works unchanged. The ephemeral manager exposes a SetMetrics setter rather than taking the collector through its constructor, keeping the constructor signature stable across all test call sites. * [management] Add OpenTelemetry metrics for ephemeral peer cleanup Introduce counters for tracking ephemeral peer cleanup, including peers pending deletion, cleanup runs, successful deletions, and failed batches. Metrics are nil-receiver safe to ensure compatibility with test setups without telemetry. --- .../peers/ephemeral/manager/ephemeral.go | 45 ++++++- management/internals/server/controllers.go | 6 +- management/server/peer.go | 28 +++++ .../telemetry/accountmanager_metrics.go | 65 ++++++++++ management/server/telemetry/app_metrics.go | 28 +++++ .../server/telemetry/ephemeral_metrics.go | 115 ++++++++++++++++++ 6 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 management/server/telemetry/ephemeral_metrics.go diff --git a/management/internals/modules/peers/ephemeral/manager/ephemeral.go b/management/internals/modules/peers/ephemeral/manager/ephemeral.go index 758f643d0a3..0f902ea707b 100644 --- a/management/internals/modules/peers/ephemeral/manager/ephemeral.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral.go @@ -11,6 +11,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral" "github.com/netbirdio/netbird/management/server/activity" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/store" ) @@ -47,6 +48,11 @@ type EphemeralManager struct { lifeTime time.Duration cleanupWindow time.Duration + + // metrics is nil-safe; methods on telemetry.EphemeralPeersMetrics + // no-op when the receiver is nil so deployments without an app + // metrics provider work unchanged. + metrics *telemetry.EphemeralPeersMetrics } // NewEphemeralManager instantiate new EphemeralManager @@ -60,6 +66,15 @@ func NewEphemeralManager(store store.Store, peersManager peers.Manager) *Ephemer } } +// SetMetrics attaches a metrics collector. Safe to call once before +// LoadInitialPeers; later attachment is fine but earlier loads won't be +// reflected in the gauge. Pass nil to detach. +func (e *EphemeralManager) SetMetrics(m *telemetry.EphemeralPeersMetrics) { + e.peersLock.Lock() + e.metrics = m + e.peersLock.Unlock() +} + // LoadInitialPeers load from the database the ephemeral type of peers and schedule a cleanup procedure to the head // of the linked list (to the most deprecated peer). At the end of cleanup it schedules the next cleanup to the new // head. @@ -97,7 +112,9 @@ func (e *EphemeralManager) OnPeerConnected(ctx context.Context, peer *nbpeer.Pee e.peersLock.Lock() defer e.peersLock.Unlock() - e.removePeer(peer.ID) + if e.removePeer(peer.ID) { + e.metrics.DecPending(1) + } // stop the unnecessary timer if e.headPeer == nil && e.timer != nil { @@ -123,6 +140,7 @@ func (e *EphemeralManager) OnPeerDisconnected(ctx context.Context, peer *nbpeer. } e.addPeer(peer.AccountID, peer.ID, e.newDeadLine()) + e.metrics.IncPending() if e.timer == nil { delay := e.headPeer.deadline.Sub(timeNow()) + e.cleanupWindow if delay < 0 { @@ -145,6 +163,7 @@ func (e *EphemeralManager) loadEphemeralPeers(ctx context.Context) { for _, p := range peers { e.addPeer(p.AccountID, p.ID, t) } + e.metrics.AddPending(int64(len(peers))) log.WithContext(ctx).Debugf("loaded ephemeral peer(s): %d", len(peers)) } @@ -181,6 +200,15 @@ func (e *EphemeralManager) cleanup(ctx context.Context) { e.peersLock.Unlock() + // Drop the gauge by the number of entries we just took off the list, + // regardless of whether the subsequent DeletePeers call succeeds. The + // list invariant is what the gauge tracks; failed delete batches are + // counted separately via CountCleanupError so we can still see them. + if len(deletePeers) > 0 { + e.metrics.CountCleanupRun() + e.metrics.DecPending(int64(len(deletePeers))) + } + peerIDsPerAccount := make(map[string][]string) for id, p := range deletePeers { peerIDsPerAccount[p.accountID] = append(peerIDsPerAccount[p.accountID], id) @@ -191,7 +219,10 @@ func (e *EphemeralManager) cleanup(ctx context.Context) { err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true) if err != nil { log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err) + e.metrics.CountCleanupError() + continue } + e.metrics.CountPeersCleaned(int64(len(peerIDs))) } } @@ -211,9 +242,12 @@ func (e *EphemeralManager) addPeer(accountID string, peerID string, deadline tim e.tailPeer = ep } -func (e *EphemeralManager) removePeer(id string) { +// removePeer drops the entry from the linked list. Returns true if a +// matching entry was found and removed so callers can keep the pending +// metric gauge in sync. +func (e *EphemeralManager) removePeer(id string) bool { if e.headPeer == nil { - return + return false } if e.headPeer.id == id { @@ -221,7 +255,7 @@ func (e *EphemeralManager) removePeer(id string) { if e.tailPeer.id == id { e.tailPeer = nil } - return + return true } for p := e.headPeer; p.next != nil; p = p.next { @@ -231,9 +265,10 @@ func (e *EphemeralManager) removePeer(id string) { e.tailPeer = p } p.next = p.next.next - return + return true } } + return false } func (e *EphemeralManager) isPeerOnList(id string) bool { diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index 89bdf0abe39..794c3ebe0ac 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -112,7 +112,11 @@ func (s *BaseServer) AuthManager() auth.Manager { func (s *BaseServer) EphemeralManager() ephemeral.Manager { return Create(s, func() ephemeral.Manager { - return manager.NewEphemeralManager(s.Store(), s.PeersManager()) + em := manager.NewEphemeralManager(s.Store(), s.PeersManager()) + if metrics := s.Metrics(); metrics != nil { + em.SetMetrics(metrics.EphemeralPeersMetrics()) + } + return em }) } diff --git a/management/server/peer.go b/management/server/peer.go index 4790a5aab42..34b681f5179 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -28,6 +28,7 @@ import ( "github.com/netbirdio/netbird/management/server/activity" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/shared/management/status" ) @@ -72,19 +73,32 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID // Disconnects use MarkPeerDisconnected and require the session to match // exactly; see PeerStatus.SessionStartedAt for the protocol. func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64) error { + start := time.Now() + defer func() { + am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start)) + }() + peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) if err != nil { + outcome := telemetry.PeerStatusError + if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { + outcome = telemetry.PeerStatusPeerNotFound + } + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, outcome) return err } updated, err := am.Store.MarkPeerConnectedIfNewerSession(ctx, accountID, peer.ID, sessionStartedAt) if err != nil { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusError) return err } if !updated { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusStale) log.WithContext(ctx).Tracef("peer %s already has a newer session in store, skipping connect", peer.ID) return nil } + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied) if am.geo != nil && realIP != nil { am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP) @@ -119,19 +133,33 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK // newer stream has already taken ownership of the peer — disconnects from // the older stream are ignored. LastSeen is written by the database. func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerPubKey string, accountID string, sessionStartedAt int64) error { + start := time.Now() + defer func() { + am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusDisconnect, time.Since(start)) + }() + peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) if err != nil { + outcome := telemetry.PeerStatusError + if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { + outcome = telemetry.PeerStatusPeerNotFound + } + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, outcome) return err } updated, err := am.Store.MarkPeerDisconnectedIfSameSession(ctx, accountID, peer.ID, sessionStartedAt) if err != nil { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusError) return err } if !updated { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusStale) log.WithContext(ctx).Tracef("peer %s session token mismatch on disconnect (token=%d), skipping", peer.ID, sessionStartedAt) + return nil } + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied) return nil } diff --git a/management/server/telemetry/accountmanager_metrics.go b/management/server/telemetry/accountmanager_metrics.go index 518aae7eba8..bb6fb7e12e4 100644 --- a/management/server/telemetry/accountmanager_metrics.go +++ b/management/server/telemetry/accountmanager_metrics.go @@ -16,6 +16,8 @@ type AccountManagerMetrics struct { getPeerNetworkMapDurationMs metric.Float64Histogram networkMapObjectCount metric.Int64Histogram peerMetaUpdateCount metric.Int64Counter + peerStatusUpdateCounter metric.Int64Counter + peerStatusUpdateDurationMs metric.Float64Histogram } // NewAccountManagerMetrics creates an instance of AccountManagerMetrics @@ -64,6 +66,24 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account return nil, err } + // peerStatusUpdateCounter records every attempt to mark a peer as connected or disconnected + peerStatusUpdateCounter, err := meter.Int64Counter("management.account.peer.status.update.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of peer status update attempts, labeled by operation (connect|disconnect) and outcome (applied|stale|error|peer_not_found)")) + if err != nil { + return nil, err + } + + peerStatusUpdateDurationMs, err := meter.Float64Histogram("management.account.peer.status.update.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithExplicitBucketBoundaries( + 1, 5, 15, 25, 50, 100, 250, 500, 1000, 2000, 5000, + ), + metric.WithDescription("Duration of a peer status update (fence UPDATE + post-write side effects), labeled by operation")) + if err != nil { + return nil, err + } + return &AccountManagerMetrics{ ctx: ctx, getPeerNetworkMapDurationMs: getPeerNetworkMapDurationMs, @@ -71,10 +91,35 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account updateAccountPeersCounter: updateAccountPeersCounter, networkMapObjectCount: networkMapObjectCount, peerMetaUpdateCount: peerMetaUpdateCount, + peerStatusUpdateCounter: peerStatusUpdateCounter, + peerStatusUpdateDurationMs: peerStatusUpdateDurationMs, }, nil } +// PeerStatusOperation labels the kind of fence-locked peer status write. +type PeerStatusOperation string + +// PeerStatusOutcome labels how a fence-locked peer status write resolved. +type PeerStatusOutcome string + +const ( + PeerStatusConnect PeerStatusOperation = "connect" + PeerStatusDisconnect PeerStatusOperation = "disconnect" + + // PeerStatusApplied — the fence WHERE matched and the UPDATE landed. + PeerStatusApplied PeerStatusOutcome = "applied" + // PeerStatusStale — the fence WHERE rejected the write because a + // newer session has already taken ownership (connect: stored token + // >= incoming; disconnect: stored token != incoming). + PeerStatusStale PeerStatusOutcome = "stale" + // PeerStatusError — the store returned a non-NotFound error. + PeerStatusError PeerStatusOutcome = "error" + // PeerStatusPeerNotFound — the peer lookup failed (the peer was + // deleted between the gRPC sync handshake and the status write). + PeerStatusPeerNotFound PeerStatusOutcome = "peer_not_found" +) + // CountUpdateAccountPeersDuration counts the duration of updating account peers func (metrics *AccountManagerMetrics) CountUpdateAccountPeersDuration(duration time.Duration) { metrics.updateAccountPeersDurationMs.Record(metrics.ctx, float64(duration.Nanoseconds())/1e6) @@ -104,3 +149,23 @@ func (metrics *AccountManagerMetrics) CountUpdateAccountPeersTriggered(resource, func (metrics *AccountManagerMetrics) CountPeerMetUpdate() { metrics.peerMetaUpdateCount.Add(metrics.ctx, 1) } + +// CountPeerStatusUpdate increments the connect/disconnect counter, +// labeled by operation and outcome. Both labels are bounded enums. +func (metrics *AccountManagerMetrics) CountPeerStatusUpdate(op PeerStatusOperation, outcome PeerStatusOutcome) { + metrics.peerStatusUpdateCounter.Add(metrics.ctx, 1, + metric.WithAttributes( + attribute.String("operation", string(op)), + attribute.String("outcome", string(outcome)), + ), + ) +} + +// RecordPeerStatusUpdateDuration records the wall-clock time spent +// running a peer status update (including post-write side effects), +// labeled by operation. +func (metrics *AccountManagerMetrics) RecordPeerStatusUpdateDuration(op PeerStatusOperation, d time.Duration) { + metrics.peerStatusUpdateDurationMs.Record(metrics.ctx, float64(d.Nanoseconds())/1e6, + metric.WithAttributes(attribute.String("operation", string(op))), + ) +} diff --git a/management/server/telemetry/app_metrics.go b/management/server/telemetry/app_metrics.go index 1fd78bc3a93..fd9087a9659 100644 --- a/management/server/telemetry/app_metrics.go +++ b/management/server/telemetry/app_metrics.go @@ -29,6 +29,7 @@ type MockAppMetrics struct { StoreMetricsFunc func() *StoreMetrics UpdateChannelMetricsFunc func() *UpdateChannelMetrics AddAccountManagerMetricsFunc func() *AccountManagerMetrics + EphemeralPeersMetricsFunc func() *EphemeralPeersMetrics } // GetMeter mocks the GetMeter function of the AppMetrics interface @@ -103,6 +104,14 @@ func (mock *MockAppMetrics) AccountManagerMetrics() *AccountManagerMetrics { return nil } +// EphemeralPeersMetrics mocks the MockAppMetrics function of the EphemeralPeersMetrics interface +func (mock *MockAppMetrics) EphemeralPeersMetrics() *EphemeralPeersMetrics { + if mock.EphemeralPeersMetricsFunc != nil { + return mock.EphemeralPeersMetricsFunc() + } + return nil +} + // AppMetrics is metrics interface type AppMetrics interface { GetMeter() metric2.Meter @@ -114,6 +123,7 @@ type AppMetrics interface { StoreMetrics() *StoreMetrics UpdateChannelMetrics() *UpdateChannelMetrics AccountManagerMetrics() *AccountManagerMetrics + EphemeralPeersMetrics() *EphemeralPeersMetrics } // defaultAppMetrics are core application metrics based on OpenTelemetry https://opentelemetry.io/ @@ -129,6 +139,7 @@ type defaultAppMetrics struct { storeMetrics *StoreMetrics updateChannelMetrics *UpdateChannelMetrics accountManagerMetrics *AccountManagerMetrics + ephemeralMetrics *EphemeralPeersMetrics } // IDPMetrics returns metrics for the idp package @@ -161,6 +172,11 @@ func (appMetrics *defaultAppMetrics) AccountManagerMetrics() *AccountManagerMetr return appMetrics.accountManagerMetrics } +// EphemeralPeersMetrics returns metrics for the ephemeral peer cleanup loop +func (appMetrics *defaultAppMetrics) EphemeralPeersMetrics() *EphemeralPeersMetrics { + return appMetrics.ephemeralMetrics +} + // Close stop application metrics HTTP handler and closes listener. func (appMetrics *defaultAppMetrics) Close() error { if appMetrics.listener == nil { @@ -245,6 +261,11 @@ func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) { return nil, fmt.Errorf("failed to initialize account manager metrics: %w", err) } + ephemeralMetrics, err := NewEphemeralPeersMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize ephemeral peers metrics: %w", err) + } + return &defaultAppMetrics{ Meter: meter, ctx: ctx, @@ -254,6 +275,7 @@ func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) { storeMetrics: storeMetrics, updateChannelMetrics: updateChannelMetrics, accountManagerMetrics: accountManagerMetrics, + ephemeralMetrics: ephemeralMetrics, }, nil } @@ -290,6 +312,11 @@ func NewAppMetricsWithMeter(ctx context.Context, meter metric2.Meter) (AppMetric return nil, fmt.Errorf("failed to initialize account manager metrics: %w", err) } + ephemeralMetrics, err := NewEphemeralPeersMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize ephemeral peers metrics: %w", err) + } + return &defaultAppMetrics{ Meter: meter, ctx: ctx, @@ -300,5 +327,6 @@ func NewAppMetricsWithMeter(ctx context.Context, meter metric2.Meter) (AppMetric storeMetrics: storeMetrics, updateChannelMetrics: updateChannelMetrics, accountManagerMetrics: accountManagerMetrics, + ephemeralMetrics: ephemeralMetrics, }, nil } diff --git a/management/server/telemetry/ephemeral_metrics.go b/management/server/telemetry/ephemeral_metrics.go new file mode 100644 index 00000000000..a7fb432f860 --- /dev/null +++ b/management/server/telemetry/ephemeral_metrics.go @@ -0,0 +1,115 @@ +package telemetry + +import ( + "context" + + "go.opentelemetry.io/otel/metric" +) + +// EphemeralPeersMetrics tracks the ephemeral peer cleanup pipeline: how +// many peers are currently scheduled for deletion, how many tick runs +// the cleaner has performed, how many peers it has removed, and how +// many delete batches failed. +type EphemeralPeersMetrics struct { + ctx context.Context + + pending metric.Int64UpDownCounter + cleanupRuns metric.Int64Counter + peersCleaned metric.Int64Counter + errors metric.Int64Counter +} + +// NewEphemeralPeersMetrics constructs the ephemeral cleanup counters. +func NewEphemeralPeersMetrics(ctx context.Context, meter metric.Meter) (*EphemeralPeersMetrics, error) { + pending, err := meter.Int64UpDownCounter("management.ephemeral.peers.pending", + metric.WithUnit("1"), + metric.WithDescription("Number of ephemeral peers currently waiting to be cleaned up")) + if err != nil { + return nil, err + } + + cleanupRuns, err := meter.Int64Counter("management.ephemeral.cleanup.runs.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of ephemeral cleanup ticks that processed at least one peer")) + if err != nil { + return nil, err + } + + peersCleaned, err := meter.Int64Counter("management.ephemeral.peers.cleaned.counter", + metric.WithUnit("1"), + metric.WithDescription("Total number of ephemeral peers deleted by the cleanup loop")) + if err != nil { + return nil, err + } + + errors, err := meter.Int64Counter("management.ephemeral.cleanup.errors.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of ephemeral cleanup batches (per account) that failed to delete")) + if err != nil { + return nil, err + } + + return &EphemeralPeersMetrics{ + ctx: ctx, + pending: pending, + cleanupRuns: cleanupRuns, + peersCleaned: peersCleaned, + errors: errors, + }, nil +} + +// All methods are nil-receiver safe so callers that haven't wired metrics +// (tests, self-hosted with metrics off) can invoke them unconditionally. + +// IncPending bumps the pending gauge when a peer is added to the cleanup list. +func (m *EphemeralPeersMetrics) IncPending() { + if m == nil { + return + } + m.pending.Add(m.ctx, 1) +} + +// AddPending bumps the pending gauge by n — used at startup when the +// initial set of ephemeral peers is loaded from the store. +func (m *EphemeralPeersMetrics) AddPending(n int64) { + if m == nil || n <= 0 { + return + } + m.pending.Add(m.ctx, n) +} + +// DecPending decreases the pending gauge — used both when a peer reconnects +// before its deadline (removed from the list) and when a cleanup tick +// actually deletes it. +func (m *EphemeralPeersMetrics) DecPending(n int64) { + if m == nil || n <= 0 { + return + } + m.pending.Add(m.ctx, -n) +} + +// CountCleanupRun records one cleanup pass that processed >0 peers. Idle +// ticks (nothing to do) deliberately don't increment so the rate +// reflects useful work. +func (m *EphemeralPeersMetrics) CountCleanupRun() { + if m == nil { + return + } + m.cleanupRuns.Add(m.ctx, 1) +} + +// CountPeersCleaned records the number of peers a single tick deleted. +func (m *EphemeralPeersMetrics) CountPeersCleaned(n int64) { + if m == nil || n <= 0 { + return + } + m.peersCleaned.Add(m.ctx, n) +} + +// CountCleanupError records a failed delete batch. +func (m *EphemeralPeersMetrics) CountCleanupError() { + if m == nil { + return + } + m.errors.Add(m.ctx, 1) +} From 9d189bb3e83346ca002f67ba272225d4d412ecdb Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 12:11:13 +0200 Subject: [PATCH 043/151] Restore Hextile SolidFill and Zlib encoding paths --- client/vnc/server/rfb.go | 75 +++++++++++++++++++++++++++++ client/vnc/server/session.go | 14 ++++++ client/vnc/server/session_encode.go | 27 +++++++++-- 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index bf25d763264..a7c341de5f1 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -77,6 +77,10 @@ const ( pseudoEncCompressLevelMin = -256 pseudoEncCompressLevelMax = -247 + // Hextile sub-encoding bits used by the SolidFill fast path. + hextileBackgroundSpecified = 0x02 + hextileSubSize = 16 + // Tight compression-control byte top nibble. Stream-reset bits 0-3 // (one per zlib stream) are unused while we run a single stream. tightFillSubenc = 0x80 @@ -248,6 +252,73 @@ func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte return buf } +// encodeZlibRect encodes a framebuffer region using the standalone Zlib +// encoding. The zlib stream is continuous for the entire VNC session: the +// client keeps a single inflate context and reuses it across rects. The +// returned buffer includes the 4-byte FramebufferUpdate header. +func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zlibState) []byte { + zw, zbuf := z.w, z.buf + zbuf.Reset() + + rowBytes := w * 4 + total := rowBytes * h + if cap(z.scratch) < total { + z.scratch = make([]byte, total) + } + scratch := z.scratch[:total] + writePixels(scratch, img, pf, rect{x, y, w, h}) + for row := 0; row < h; row++ { + if _, err := zw.Write(scratch[row*rowBytes : (row+1)*rowBytes]); err != nil { + log.Debugf("zlib write row %d: %v", row, err) + return nil + } + } + if err := zw.Flush(); err != nil { + log.Debugf("zlib flush: %v", err) + return nil + } + compressed := zbuf.Bytes() + + buf := make([]byte, 4+12+4+len(compressed)) + buf[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(buf[2:4], 1) + binary.BigEndian.PutUint16(buf[4:6], uint16(x)) + binary.BigEndian.PutUint16(buf[6:8], uint16(y)) + binary.BigEndian.PutUint16(buf[8:10], uint16(w)) + binary.BigEndian.PutUint16(buf[10:12], uint16(h)) + binary.BigEndian.PutUint32(buf[12:16], uint32(encZlib)) + binary.BigEndian.PutUint32(buf[16:20], uint32(len(compressed))) + copy(buf[20:], compressed) + return buf +} + +// encodeHextileSolidRect emits a Hextile-encoded rectangle whose every +// pixel is the same colour. The first sub-tile carries the background +// pixel; remaining sub-tiles inherit it via a zero sub-encoding byte, +// collapsing a uniform 64×64 tile down to ~20 bytes. The returned buffer +// starts with the 12-byte rect header; callers prepend a FramebufferUpdate +// header. +func encodeHextileSolidRect(r, g, b byte, pf clientPixelFormat, rc rect) []byte { + cols := (rc.w + hextileSubSize - 1) / hextileSubSize + rows := (rc.h + hextileSubSize - 1) / hextileSubSize + subs := cols * rows + // One sub-encoding byte plus a 32bpp pixel for the first sub-tile, then + // one zero byte per remaining sub-tile to inherit the background. + bodySize := 1 + 4 + (subs - 1) + buf := make([]byte, 12+bodySize) + + binary.BigEndian.PutUint16(buf[0:2], uint16(rc.x)) + binary.BigEndian.PutUint16(buf[2:4], uint16(rc.y)) + binary.BigEndian.PutUint16(buf[4:6], uint16(rc.w)) + binary.BigEndian.PutUint16(buf[6:8], uint16(rc.h)) + binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile)) + + buf[12] = hextileBackgroundSpecified + pixel := (uint32(r) << pf.rShift) | (uint32(g) << pf.gShift) | (uint32(b) << pf.bShift) + binary.LittleEndian.PutUint32(buf[13:17], pixel) + return buf +} + // writePixels writes a rectangle of img into dst as 32bpp little-endian // pixels at the negotiated RGB shifts. The pixel format is constrained at // SetPixelFormat time so we can assume 4 bytes per pixel, 8-bit channels, @@ -719,6 +790,10 @@ func sampledColorCountInto(seen map[uint32]struct{}, img *image.RGBA, x, y, w, h type zlibState struct { buf *bytes.Buffer w *zlib.Writer + // scratch stages the packed pixel stream for a rect before it is fed + // to the deflater. Grown to the largest rect seen in the session and + // reused to keep the steady-state encode allocation-free. + scratch []byte } func newZlibStateLevel(level int) *zlibState { diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index eae4fa85ef5..c4d066f73e3 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -50,7 +50,10 @@ type session struct { pf clientPixelFormat useTight bool useCopyRect bool + useZlib bool + useHextile bool tight *tightState + zlib *zlibState copyRectDet *copyRectDetector // Pseudo-encodings the client advertised support for. Updated under // encMu by handleSetEncodings and read by the encoder goroutine. @@ -336,6 +339,8 @@ func (s *session) handleSetEncodings() error { func (s *session) resetEncodingCaps() { s.useTight = false s.useCopyRect = false + s.useZlib = false + s.useHextile = false s.clientSupportsDesktopSize = false s.clientSupportsExtendedDesktopSize = false s.clientSupportsDesktopName = false @@ -378,6 +383,15 @@ func (s *session) applyEncoding(enc int32) string { case encTight: s.useTight = true return "tight" + case encZlib: + s.useZlib = true + if s.zlib == nil { + s.zlib = newZlibStateLevel(zlibLevelFor(-1)) + } + return "zlib" + case encHextile: + s.useHextile = true + return "hextile" } if enc >= pseudoEncQualityLevelMin && enc <= pseudoEncQualityLevelMax { s.clientJPEGQuality = int(enc - pseudoEncQualityLevelMin) diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 7605eff2d7c..48ba252843c 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -291,12 +291,11 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { pf := s.pf useTight := s.useTight tight := s.tight + useZlib := s.useZlib + zlib := s.zlib s.encMu.RUnlock() if useTight && tight != nil && pfIsTightCompatible(pf) { - // Tight encodes arbitrary sizes natively (Fill for uniform, JPEG - // for photo-like, Basic+zlib otherwise). Wrap the rect bytes with - // the 4-byte FramebufferUpdate header. rectBuf := encodeTightRect(img, pf, 0, 0, w, h, tight) buf := make([]byte, 4+len(rectBuf)) buf[0] = serverFramebufferUpdate @@ -308,6 +307,14 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { return err } + if useZlib && zlib != nil { + buf := encodeZlibRect(img, pf, 0, 0, w, h, zlib) + s.writeMu.Lock() + _, err := s.conn.Write(buf) + s.writeMu.Unlock() + return err + } + buf := encodeRawRect(img, pf, 0, 0, w, h) s.writeMu.Lock() _, err := s.conn.Write(buf) @@ -366,13 +373,27 @@ func (s *session) sendDirtyAndMoves(img *image.RGBA, moves []copyRectMove, rects func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { s.encMu.RLock() pf := s.pf + useHextile := s.useHextile useTight := s.useTight tight := s.tight + useZlib := s.useZlib + zlib := s.zlib s.encMu.RUnlock() + if useHextile { + if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform { + r := byte(pixel) + g := byte(pixel >> 8) + b := byte(pixel >> 16) + return encodeHextileSolidRect(r, g, b, pf, rect{x, y, w, h}) + } + } if useTight && tight != nil && pfIsTightCompatible(pf) { return encodeTightRect(img, pf, x, y, w, h, tight) } + if useZlib && zlib != nil { + return encodeZlibRect(img, pf, x, y, w, h, zlib)[4:] + } return encodeRawRect(img, pf, x, y, w, h)[4:] } From 24a5f2252ce89f706b3d047d9ae252a2fcbfc264 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 12:19:05 +0200 Subject: [PATCH 044/151] Accept any RGB shift permutation as Tight-compatible per RFB 7.7.6 --- client/vnc/server/session_encode.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 48ba252843c..0a5963c6459 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -409,8 +409,12 @@ func drainRequests(ch chan fbRequest) int { } // pfIsTightCompatible reports whether the negotiated client pixel format -// matches Tight's TPIXEL constraint: standard RGB shifts (R=16, G=8, B=0). -// bpp/endianness/channel-max are already locked at SetPixelFormat time. +// satisfies Tight's TPIXEL constraint (RFB 7.7.6): the three RGB shifts form +// a permutation of {0, 8, 16} so the colour values live in the low 24 bits. +// bpp, endianness, and 8-bit channels are already enforced at SetPixelFormat +// time. Any permutation works because Tight always emits a three-byte R, G, +// B triple regardless of where the client stores each channel. func pfIsTightCompatible(pf clientPixelFormat) bool { - return pf.rShift == 16 && pf.gShift == 8 && pf.bShift == 0 + shifts := uint32(1)< Date: Tue, 19 May 2026 12:31:09 +0200 Subject: [PATCH 045/151] Surface DXGI fallback to GDI at warn level on Windows --- client/vnc/server/capture_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/vnc/server/capture_windows.go b/client/vnc/server/capture_windows.go index 1376ca8227d..60c3afb66d4 100644 --- a/client/vnc/server/capture_windows.go +++ b/client/vnc/server/capture_windows.go @@ -528,7 +528,7 @@ func (w *captureWorker) createCapturer() (frameCapturer, error) { log.Info("using DXGI Desktop Duplication for capture") return dc, nil } - log.Debugf("DXGI unavailable (%v), falling back to GDI", err) + log.Warnf("DXGI Desktop Duplication unavailable, falling back to slower GDI BitBlt: %v", err) gc, err := newGDICapturer() if err != nil { return nil, err From 393c102f459c48d7ce86be145cc4b655ed1816e8 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 12:31:09 +0200 Subject: [PATCH 046/151] Throttle VNC encoder JPEG quality and skip frames under write backpressure --- client/vnc/server/metrics_conn.go | 50 +++++++++++++++++++++--- client/vnc/server/session_encode.go | 59 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/client/vnc/server/metrics_conn.go b/client/vnc/server/metrics_conn.go index bc0f36d5711..2baec750ace 100644 --- a/client/vnc/server/metrics_conn.go +++ b/client/vnc/server/metrics_conn.go @@ -51,12 +51,18 @@ type metricsConn struct { maxFBUBytes uint64 maxFBURects uint64 - tickMu sync.Mutex - tickStart time.Time - tickPrevB uint64 - tickPrevW uint64 - tickPrevF uint64 - tickPrevNS uint64 + tickMu sync.Mutex + tickStart time.Time + tickPrevB uint64 + tickPrevW uint64 + tickPrevF uint64 + tickPrevNS uint64 + + // busyMu guards the sliding window used by BusyFraction. + busyMu sync.Mutex + busyLastTime time.Time + busyLastNanos uint64 + busyFraction float64 closeOnce sync.Once done chan struct{} @@ -131,6 +137,38 @@ func (m *metricsConn) flushTick(final bool) { }) } +// BusyFraction reports the fraction of recent wall time that Write spent +// blocked in the underlying socket, as an exponentially smoothed value in +// [0, 1]. Approximates downstream backpressure: persistent values near 1 +// mean the socket cannot keep up with the encoder's output. Callers can +// throttle JPEG quality or skip frames in response. +func (m *metricsConn) BusyFraction() float64 { + now := time.Now() + ns := atomic.LoadUint64(&m.writeNanos) + + m.busyMu.Lock() + defer m.busyMu.Unlock() + if m.busyLastTime.IsZero() { + m.busyLastTime = now + m.busyLastNanos = ns + return 0 + } + period := now.Sub(m.busyLastTime) + if period < 50*time.Millisecond { + return m.busyFraction + } + delta := ns - m.busyLastNanos + sample := float64(delta) / float64(period.Nanoseconds()) + if sample > 1 { + sample = 1 + } + const alpha = 0.4 + m.busyFraction = alpha*sample + (1-alpha)*m.busyFraction + m.busyLastTime = now + m.busyLastNanos = ns + return m.busyFraction +} + // isFBUHeader reports whether the given Write payload is the 4-byte // FramebufferUpdate header (message type 0, padding 0, rect-count high // byte). Rect bodies are written separately by sendDirtyAndMoves, so the diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 0a5963c6459..f34a584006b 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -37,6 +37,11 @@ func (s *session) processFBRequest(req fbRequest) error { return err } + busy := s.applyBackpressure() + if busy >= backpressureSkipThreshold { + return s.sendEmptyUpdate() + } + img, err := s.captureFrame() if errors.Is(err, errFrameUnchanged) { // macOS hashes the raw capture bytes and short-circuits when the @@ -123,6 +128,60 @@ func (s *session) processIncremental(img *image.RGBA) error { return nil } +// backpressureSkipThreshold is the BusyFraction at and above which we drop +// the next encode entirely and respond with an empty FramebufferUpdate. +// Above this level the encoder would only stack more bytes behind a socket +// that is already write-blocked, raising end-to-end latency. +const backpressureSkipThreshold = 0.65 + +// backpressureRampStart is where adaptive quality begins clipping. Below +// this fraction the honoured client quality is used as-is. +const backpressureRampStart = 0.2 + +// backpressureMinQuality is the floor JPEG quality picked when the socket +// is fully saturated short of the skip threshold. +const backpressureMinQuality = 25 + +// applyBackpressure samples the socket BusyFraction (if available) and, if +// Tight is in use, ramps the active JPEG quality from the client-honoured +// value down to backpressureMinQuality as the fraction climbs from +// backpressureRampStart toward backpressureSkipThreshold. Returns the +// observed fraction so the caller can decide whether to skip the frame. +func (s *session) applyBackpressure() float64 { + type busyReporter interface{ BusyFraction() float64 } + bs, ok := s.conn.(busyReporter) + if !ok { + return 0 + } + frac := bs.BusyFraction() + + s.encMu.RLock() + tight := s.tight + s.encMu.RUnlock() + if tight == nil { + return frac + } + + base := jpegQualityForLevel(tight.qualityLevel) + if base == 0 { + base = tightJPEGQuality + } + q := base + if frac > backpressureRampStart { + span := backpressureSkipThreshold - backpressureRampStart + t := (frac - backpressureRampStart) / span + if t > 1 { + t = 1 + } + q = base - int(float64(base-backpressureMinQuality)*t) + if q < backpressureMinQuality { + q = backpressureMinQuality + } + } + tight.jpegQualityOverride = q + return frac +} + // captureErrorLog emits one log line on the first failure after success, // then at most once every captureErrThrottle while the capturer keeps // failing. The "recovered" transition is logged once when err is nil and From 5eec9962bad254bf8425974ba2969013446fcc63 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 12:40:05 +0200 Subject: [PATCH 047/151] Honour client JPEG quality fully now that backpressure caps it dynamically --- client/vnc/server/rfb.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index a7c341de5f1..11a288eb3cb 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -544,10 +544,9 @@ func newTightStateWithLevels(qualityLevel, compressLevel int) *tightState { // jpegQualityForLevel maps a 0..9 client preference to a JPEG quality value. // Returns 0 when no preference is set (-1), letting the encoder fall back -// to the area-based tiers. The output is capped at jpegQualityClientCap -// so a client asking for the highest quality does not push per-frame JPEG -// byte counts into a regime that overwhelms bandwidth-constrained -// transports. Within the cap the mapping is still linear. +// to the area-based tiers. The encoder lowers this dynamically when the +// socket is backpressured, so this routine emits the unclamped, client- +// requested value. func jpegQualityForLevel(level int) int { if level < 0 { return 0 @@ -555,19 +554,9 @@ func jpegQualityForLevel(level int) int { if level > 9 { level = 9 } - q := 30 + level*7 - if q > jpegQualityClientCap { - q = jpegQualityClientCap - } - return q + return 30 + level*7 } -// jpegQualityClientCap upper-bounds the JPEG quality we honour from the -// client's QualityLevel pseudo-encoding. 50 keeps full-screen JPEGs in -// the same byte range as the area-tiered defaults used when the client -// does not express a preference. -const jpegQualityClientCap = 50 - // zlibLevelFor maps a 0..9 client preference to a zlib compression level. // Level 0 ("no compression") would emit larger output than input on most // rects, so we floor to BestSpeed (1). -1 (no preference) also picks From b3f0f53a23204b305ef4a139e5dfdb276330ab8e Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 12:42:38 +0200 Subject: [PATCH 048/151] Collapse dirty rects to their bounding box when the bbox is densely dirty --- client/vnc/server/session.go | 15 ++++++++++ client/vnc/server/session_encode.go | 44 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index c4d066f73e3..0228701c626 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -32,6 +32,21 @@ const ( fullFramePromoteDen = 100 ) +// bboxPromoteDensityPct collapses the coalesced rect list down to its +// bounding box when the dirty pixels occupy at least this fraction of the +// bbox. Catches the "windowed video" case where the player area dirties as +// a dense block but is split into many sibling rects by overlays or by +// non-uniform tile coverage. Sending one JPEG over the bbox beats sending +// dozens of small JPEGs that each carry their own header and Tight stream +// restart. +const ( + bboxPromoteDensityPct = 70 + // bboxPromoteMinArea avoids promoting a handful of small scattered + // rects whose bbox would span most of the screen and pull in mostly + // clean pixels. + bboxPromoteMinArea = tileSize * tileSize * 16 +) + type session struct { conn net.Conn capturer ScreenCapturer diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index f34a584006b..4d650159135 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -120,6 +120,11 @@ func (s *session) processIncremental(img *image.RGBA) error { s.refreshCopyRectIndex() return nil } + if len(moves) == 0 { + if bb, ok := promoteToBoundingBox(rects); ok { + rects = bb + } + } if err := s.sendDirtyAndMoves(img, moves, rects); err != nil { return err } @@ -311,6 +316,45 @@ func (s *session) captureFrame() (*image.RGBA, error) { return s.curFrame, nil } +// promoteToBoundingBox replaces the rect list with a single rect covering +// the bounding box of all inputs, provided the bbox is at least +// bboxPromoteMinArea and the dirty pixels fill at least +// bboxPromoteDensityPct of it. Returns the new rect list and true when the +// promotion fires; otherwise returns nil, false and the caller keeps the +// original list. +func promoteToBoundingBox(rects [][4]int) ([][4]int, bool) { + if len(rects) < 2 { + return nil, false + } + x0, y0 := rects[0][0], rects[0][1] + x1, y1 := x0+rects[0][2], y0+rects[0][3] + dirty := 0 + for _, r := range rects { + if r[0] < x0 { + x0 = r[0] + } + if r[1] < y0 { + y0 = r[1] + } + if r[0]+r[2] > x1 { + x1 = r[0] + r[2] + } + if r[1]+r[3] > y1 { + y1 = r[1] + r[3] + } + dirty += r[2] * r[3] + } + w, h := x1-x0, y1-y0 + bbox := w * h + if bbox < bboxPromoteMinArea { + return nil, false + } + if dirty*100 < bbox*bboxPromoteDensityPct { + return nil, false + } + return [][4]int{{x0, y0, w, h}}, true +} + // shouldPromoteToFullFrame returns true when the dirty rect set covers a // large enough fraction of the screen that a single full-frame zlib rect // beats per-tile encoding on both CPU time and wire bytes. The crossover From 2285db2b629e400a37e7a741ccf07ae8388ed1e9 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 13:08:47 +0200 Subject: [PATCH 049/151] Treat ExtendedClipboard messages with the Caps bit as Caps regardless of co-set action bits --- client/vnc/server/session_clipboard.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/vnc/server/session_clipboard.go b/client/vnc/server/session_clipboard.go index 3b3e13abe21..c4554970faa 100644 --- a/client/vnc/server/session_clipboard.go +++ b/client/vnc/server/session_clipboard.go @@ -139,11 +139,16 @@ func (s *session) handleExtCutText(payloadLen uint32) error { formats := flags & extClipFormatMask rest := buf[4:] - switch action { - case extClipActionCaps: + // A Caps message sets the Caps bit alongside one bit per action the + // peer supports, so the action byte is multi-bit. Detect it first; the + // remaining actions are single-bit and are dispatched after. + if action&extClipActionCaps != 0 { // Client max sizes are informational for us today: we only emit // text and already cap it at extClipMaxText. return nil + } + + switch action { case extClipActionRequest: if formats&extClipFormatText != 0 { return s.sendExtClipProvideText() From fe15688f208ca36f9a9da54cb349ef1cf09f5a99 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 13:04:05 +0200 Subject: [PATCH 050/151] Emit Cursor pseudo-encoding on Linux, Windows, and macOS --- client/vnc/server/capture_darwin.go | 5 + client/vnc/server/capture_windows.go | 11 + client/vnc/server/capture_x11.go | 17 ++ client/vnc/server/cursor_darwin.go | 169 +++++++++++++ client/vnc/server/cursor_windows.go | 350 +++++++++++++++++++++++++++ client/vnc/server/cursor_x11.go | 87 +++++++ client/vnc/server/rfb.go | 1 + client/vnc/server/server.go | 11 + client/vnc/server/session.go | 21 ++ client/vnc/server/session_cursor.go | 87 +++++++ client/vnc/server/session_encode.go | 81 +++++-- 11 files changed, 815 insertions(+), 25 deletions(-) create mode 100644 client/vnc/server/cursor_darwin.go create mode 100644 client/vnc/server/cursor_windows.go create mode 100644 client/vnc/server/cursor_x11.go create mode 100644 client/vnc/server/session_cursor.go diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index 2efe02f0807..2144fd96cda 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -91,6 +91,11 @@ type CGCapturer struct { hashSeed maphash.Seed lastHash uint64 hasHash bool + // cursor lazily binds the private CGSCreateCurrentCursorImage symbol + // so we can emit the Cursor pseudo-encoding without a per-frame cost + // on builds that never query it. + cursorOnce sync.Once + cursor *cgCursor } // PrimeScreenCapturePermission triggers the macOS Screen Recording diff --git a/client/vnc/server/capture_windows.go b/client/vnc/server/capture_windows.go index 60c3afb66d4..8afbad1103d 100644 --- a/client/vnc/server/capture_windows.go +++ b/client/vnc/server/capture_windows.go @@ -266,6 +266,11 @@ type DesktopCapturer struct { wake chan struct{} // done is closed when Close is called, terminating the worker. done chan struct{} + + // cursorState holds the latest cursor sprite sampled by the worker. + // The worker calls GetCursorInfo every capture and decodes a new + // sprite only when the HCURSOR changes. + cursorState cursorState } // captureReq is a single capture request awaiting a reply. Reply channel is @@ -439,6 +444,7 @@ type captureWorker struct { desktopFails int lastDesktop string nextInitRetry time.Time + cursor cursorSampler } // handleNextRequest waits for either shutdown or a capture request and runs @@ -468,6 +474,11 @@ func (w *captureWorker) serveRequest(req captureReq) { req.reply <- captureReply{err: err} return } + if snap, err := w.cursor.sample(); err != nil { + w.c.cursorState.store(&cursorSnapshot{err: err}) + } else { + w.c.cursorState.store(snap) + } req.reply <- captureReply{img: img} } diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index fd3eb585916..804c1f02c9f 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -34,6 +34,11 @@ type X11Capturer struct { // happens on first use and on geometry change. bufs [2]*image.RGBA cur int + // cursor is the XFixes binding used to report the current sprite. + // Allocated lazily on the first Cursor call. cursorInitErr latches + // a permanent init failure so we stop retrying every frame. + cursor *xfixesCursor + cursorInitErr error } // detectX11Display finds the active X11 display and sets DISPLAY/XAUTHORITY @@ -408,6 +413,18 @@ func (p *X11Poller) Height() int { return p.h } +// Cursor satisfies cursorSource by forwarding to the lazily-initialised +// X11Capturer. Asking for the cursor on an idle poller triggers the same +// lazy X11 connection setup as a capture would. +func (p *X11Poller) Cursor() (*image.RGBA, int, int, uint64, error) { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ensureCapturerLocked(); err != nil { + return nil, 0, 0, 0, err + } + return p.capturer.Cursor() +} + // Capture returns a fresh frame, serving from the short-lived cache if a // previous caller captured within freshWindow. func (p *X11Poller) Capture() (*image.RGBA, error) { diff --git a/client/vnc/server/cursor_darwin.go b/client/vnc/server/cursor_darwin.go new file mode 100644 index 00000000000..29b85663089 --- /dev/null +++ b/client/vnc/server/cursor_darwin.go @@ -0,0 +1,169 @@ +//go:build darwin && !ios + +package server + +import ( + "fmt" + "hash/maphash" + "image" + "sync" + "unsafe" + + "github.com/ebitengine/purego" + log "github.com/sirupsen/logrus" +) + +var ( + darwinCursorOnce sync.Once + cgsCreateCursor func() uintptr + darwinCursorErr error +) + +// initDarwinCursor binds a private symbol that returns the current +// system cursor image. The classic CGSCreateCurrentCursorImage moved +// from CoreGraphics to SkyLight around macOS 13 and is gone entirely +// in Sequoia; we probe both frameworks for any of the historical +// names so this keeps working on whichever release the binding still +// exists. Without a hit the remote-cursor compositing path becomes a +// no-op and we log the candidates we tried. +func initDarwinCursor() { + darwinCursorOnce.Do(func() { + libs := []string{ + "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", + "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", + } + names := []string{ + "CGSCreateCurrentCursorImage", + "CGSCopyCurrentCursorImage", + "CGSCurrentCursorImage", + "CGSHardwareCursorActiveImage", + } + var tried []string + for _, path := range libs { + h, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + tried = append(tried, fmt.Sprintf("dlopen %s: %v", path, err)) + continue + } + for _, name := range names { + sym, err := purego.Dlsym(h, name) + if err != nil { + tried = append(tried, fmt.Sprintf("%s!%s missing", path, name)) + continue + } + purego.RegisterFunc(&cgsCreateCursor, sym) + log.Infof("macOS cursor: bound %s from %s", name, path) + return + } + } + darwinCursorErr = fmt.Errorf("no cursor image symbol available; tried: %v", tried) + }) +} + +// cgCursor holds the cached macOS cursor sprite and bumps a serial when +// the bytes change. Hotspot is left at (0, 0): the public Cocoa hot-spot +// query lives on NSCursor which is process-local and not reachable from +// our purego-based bindings; the visual cost is a small misalignment for +// non-arrow cursors (I-beam, crosshair, etc.). +type cgCursor struct { + mu sync.Mutex + hashSeed maphash.Seed + lastSum uint64 + cached *image.RGBA + serial uint64 +} + +func newCGCursor() *cgCursor { + initDarwinCursor() + return &cgCursor{hashSeed: maphash.MakeSeed()} +} + +// Cursor returns the current cursor sprite as RGBA. Errors that come from +// missing private symbols are sticky; transient empty-image responses are +// reported as such so the encoder skips this cycle. +func (c *cgCursor) Cursor() (*image.RGBA, int, int, uint64, error) { + c.mu.Lock() + defer c.mu.Unlock() + if darwinCursorErr != nil { + return nil, 0, 0, 0, darwinCursorErr + } + if cgsCreateCursor == nil { + return nil, 0, 0, 0, fmt.Errorf("CGSCreateCurrentCursorImage unavailable") + } + cgImage := cgsCreateCursor() + if cgImage == 0 { + return nil, 0, 0, 0, fmt.Errorf("no cursor image available") + } + defer cgImageRelease(cgImage) + + w := int(cgImageGetWidth(cgImage)) + h := int(cgImageGetHeight(cgImage)) + if w <= 0 || h <= 0 { + return nil, 0, 0, 0, fmt.Errorf("cursor has zero extent") + } + bytesPerRow := int(cgImageGetBytesPerRow(cgImage)) + bpp := int(cgImageGetBitsPerPixel(cgImage)) + if bpp != 32 { + return nil, 0, 0, 0, fmt.Errorf("unsupported cursor bpp: %d", bpp) + } + provider := cgImageGetDataProvider(cgImage) + if provider == 0 { + return nil, 0, 0, 0, fmt.Errorf("cursor data provider missing") + } + cfData := cgDataProviderCopyData(provider) + if cfData == 0 { + return nil, 0, 0, 0, fmt.Errorf("cursor data copy failed") + } + defer cfRelease(cfData) + dataLen := int(cfDataGetLength(cfData)) + dataPtr := cfDataGetBytePtr(cfData) + if dataPtr == 0 || dataLen == 0 { + return nil, 0, 0, 0, fmt.Errorf("cursor data empty") + } + src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen) + + sum := maphash.Bytes(c.hashSeed, src) + if c.cached != nil && sum == c.lastSum { + return c.cached, 0, 0, c.serial, nil + } + + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + srcOff := y * bytesPerRow + dstOff := y * w * 4 + for x := 0; x < w; x++ { + si := srcOff + x*4 + di := dstOff + x*4 + img.Pix[di+0] = src[si+2] + img.Pix[di+1] = src[si+1] + img.Pix[di+2] = src[si+0] + img.Pix[di+3] = src[si+3] + } + } + + c.lastSum = sum + c.cached = img + c.serial++ + return img, 0, 0, c.serial, nil +} + +// Cursor on CGCapturer satisfies cursorSource. The cgCursor wrapper is +// allocated lazily so a build that never asks for the cursor pays no cost. +func (c *CGCapturer) Cursor() (*image.RGBA, int, int, uint64, error) { + c.cursorOnce.Do(func() { + c.cursor = newCGCursor() + }) + return c.cursor.Cursor() +} + +// Cursor on MacPoller forwards to the lazy CGCapturer. ensureCapturerLocked +// returns an error when Screen Recording permission has not been granted; +// in that case there is no usable cursor source either. +func (p *MacPoller) Cursor() (*image.RGBA, int, int, uint64, error) { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ensureCapturerLocked(); err != nil { + return nil, 0, 0, 0, err + } + return p.capturer.Cursor() +} diff --git a/client/vnc/server/cursor_windows.go b/client/vnc/server/cursor_windows.go new file mode 100644 index 00000000000..6b76638b418 --- /dev/null +++ b/client/vnc/server/cursor_windows.go @@ -0,0 +1,350 @@ +//go:build windows + +package server + +import ( + "fmt" + "image" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + procGetCursorInfo = user32.NewProc("GetCursorInfo") + procGetIconInfo = user32.NewProc("GetIconInfo") + procGetObjectW = gdi32.NewProc("GetObjectW") + procGetDIBits = gdi32.NewProc("GetDIBits") +) + +const ( + cursorShowing = 0x00000001 + diRgbColors = 0 + biRgb = 0 + dibSectionBytes = 40 // sizeof(BITMAPINFOHEADER) +) + +// hiddenHandle is a sentinel stored in cursorSampler.lastHandle while +// Windows reports the cursor as hidden. It is not a valid HCURSOR value; +// real handles never collide with this constant. +const hiddenHandle = windows.Handle(^uintptr(0)) + +// transparentCursorImage returns a 1x1 fully transparent sprite. The +// client renders this as "no cursor"; emitting it explicitly lets us +// recover when an app un-hides the cursor a moment later. +func transparentCursorImage() *image.RGBA { + return image.NewRGBA(image.Rect(0, 0, 1, 1)) +} + +type winPoint struct { + X, Y int32 +} + +type winCursorInfo struct { + Size uint32 + Flags uint32 + Cursor windows.Handle + PtPos winPoint +} + +type winIconInfo struct { + FIcon int32 + XHotspot uint32 + YHotspot uint32 + HbmMask windows.Handle + HbmColor windows.Handle +} + +type winBitmap struct { + BmType int32 + BmWidth int32 + BmHeight int32 + BmWidthBytes int32 + BmPlanes uint16 + BmBitsPixel uint16 + BmBits uintptr +} + +type winBitmapInfoHeader struct { + BiSize uint32 + BiWidth int32 + BiHeight int32 + BiPlanes uint16 + BiBitCount uint16 + BiCompression uint32 + BiSizeImage uint32 + BiXPelsPerMeter int32 + BiYPelsPerMeter int32 + BiClrUsed uint32 + BiClrImportant uint32 +} + +// cursorSnapshot is the captured cursor state shared between the worker +// (which polls the OS) and the session encoder (which reads it). +type cursorSnapshot struct { + img *image.RGBA + hotX int + hotY int + serial uint64 + err error +} + +// cursorSampler captures the foreground process's cursor sprite via Win32 +// APIs. It must be called from a goroutine attached to the same window +// station and desktop as the user session (the capture worker does this +// via switchToInputDesktop). lastHandle dedupes per-shape work so we only +// touch GDI when Windows hands us a new cursor. +type cursorSampler struct { + lastHandle windows.Handle + serial uint64 + snapshot *cursorSnapshot +} + +// sample queries the current cursor and decodes a new sprite when Windows +// reports a different HCURSOR than last time. Returns the current snapshot +// regardless of whether anything changed; callers diff by serial. +func (s *cursorSampler) sample() (*cursorSnapshot, error) { + var ci winCursorInfo + ci.Size = uint32(unsafe.Sizeof(ci)) + r, _, err := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci))) + if r == 0 { + return nil, fmt.Errorf("GetCursorInfo: %w", err) + } + if ci.Flags&cursorShowing == 0 || ci.Cursor == 0 { + // Cursor temporarily hidden by an app (text fields toggle it on + // focus). Emit a 1x1 transparent sprite so the client renders no + // cursor and stay armed for the next handle change rather than + // treating this as a hard failure that would latch us off for + // the session. + if s.lastHandle == hiddenHandle { + return s.snapshot, nil + } + s.lastHandle = hiddenHandle + s.serial++ + s.snapshot = &cursorSnapshot{img: transparentCursorImage(), serial: s.serial} + return s.snapshot, nil + } + if ci.Cursor == s.lastHandle && s.snapshot != nil { + return s.snapshot, nil + } + img, hotX, hotY, err := decodeCursor(ci.Cursor) + if err != nil { + return nil, err + } + s.lastHandle = ci.Cursor + s.serial++ + s.snapshot = &cursorSnapshot{img: img, hotX: hotX, hotY: hotY, serial: s.serial} + return s.snapshot, nil +} + +// decodeCursor extracts the sprite at hCur as RGBA along with the hotspot. +// Color cursors are read from the colour bitmap with the AND mask combined +// in for alpha. Monochrome cursors collapse the two halves of the mask +// bitmap into a single visible sprite where the AND bit drives alpha. +func decodeCursor(hCur windows.Handle) (*image.RGBA, int, int, error) { + var info winIconInfo + r, _, err := procGetIconInfo.Call(uintptr(hCur), uintptr(unsafe.Pointer(&info))) + if r == 0 { + return nil, 0, 0, fmt.Errorf("GetIconInfo: %w", err) + } + defer func() { + if info.HbmMask != 0 { + procDeleteObject.Call(uintptr(info.HbmMask)) + } + if info.HbmColor != 0 { + procDeleteObject.Call(uintptr(info.HbmColor)) + } + }() + hotX, hotY := int(info.XHotspot), int(info.YHotspot) + if info.HbmColor != 0 { + img, err := decodeColorCursor(info.HbmColor, info.HbmMask) + if err != nil { + return nil, 0, 0, err + } + return img, hotX, hotY, nil + } + img, err := decodeMonoCursor(info.HbmMask) + if err != nil { + return nil, 0, 0, err + } + return img, hotX, hotY, nil +} + +// readBitmap returns the BITMAP descriptor for hbm. +func readBitmap(hbm windows.Handle) (winBitmap, error) { + var bm winBitmap + r, _, err := procGetObjectW.Call(uintptr(hbm), unsafe.Sizeof(bm), uintptr(unsafe.Pointer(&bm))) + if r == 0 { + return winBitmap{}, fmt.Errorf("GetObject: %w", err) + } + return bm, nil +} + +// dibCopy reads hbm as 32bpp top-down BGRA into a freshly allocated slice +// matching w*h*4 bytes. The bitmap may be selected into the screen DC so +// we use a memory DC to keep the call cheap. +func dibCopy(hbm windows.Handle, w, h int32) ([]byte, error) { + hdcScreen, _, _ := procGetDC.Call(0) + if hdcScreen == 0 { + return nil, fmt.Errorf("GetDC: failed") + } + defer procReleaseDC.Call(0, hdcScreen) + hdcMem, _, _ := procCreateCompatDC.Call(hdcScreen) + if hdcMem == 0 { + return nil, fmt.Errorf("CreateCompatibleDC: failed") + } + defer procDeleteDC.Call(hdcMem) + + var bih winBitmapInfoHeader + bih.BiSize = dibSectionBytes + bih.BiWidth = w + bih.BiHeight = -h // top-down + bih.BiPlanes = 1 + bih.BiBitCount = 32 + bih.BiCompression = biRgb + + buf := make([]byte, int(w)*int(h)*4) + r, _, err := procGetDIBits.Call( + hdcMem, + uintptr(hbm), + 0, + uintptr(h), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&bih)), + diRgbColors, + ) + if r == 0 { + return nil, fmt.Errorf("GetDIBits: %w", err) + } + return buf, nil +} + +// decodeColorCursor reads a 32bpp colour cursor and folds the AND mask into +// the alpha channel when the colour bitmap leaves it zero. +func decodeColorCursor(hbmColor, hbmMask windows.Handle) (*image.RGBA, error) { + bm, err := readBitmap(hbmColor) + if err != nil { + return nil, err + } + w, h := bm.BmWidth, bm.BmHeight + color, err := dibCopy(hbmColor, w, h) + if err != nil { + return nil, err + } + var mask []byte + if hbmMask != 0 { + mask, _ = dibCopy(hbmMask, w, h) + } + img := image.NewRGBA(image.Rect(0, 0, int(w), int(h))) + hasAlpha := false + for i := 0; i < len(color); i += 4 { + if color[i+3] != 0 { + hasAlpha = true + break + } + } + for y := int32(0); y < h; y++ { + for x := int32(0); x < w; x++ { + si := (y*w + x) * 4 + di := (y*w + x) * 4 + b := color[si] + g := color[si+1] + r := color[si+2] + a := color[si+3] + if !hasAlpha { + a = 255 + if mask != nil { + // AND mask: 1 = transparent, 0 = opaque. The DIB + // representation we requested is 32bpp so each "bit" + // is a 4-byte entry; we use the first byte as the + // effective AND value. + if mask[si] != 0 { + a = 0 + } + } + } + img.Pix[di+0] = r + img.Pix[di+1] = g + img.Pix[di+2] = b + img.Pix[di+3] = a + } + } + return img, nil +} + +// decodeMonoCursor handles legacy 1bpp cursors where hbmMask is twice as +// tall as the visible sprite: rows [0..h) are the AND mask and rows [h..2h) +// are the XOR mask. We render the visible half into RGBA, treating +// AND-mask=1 as transparent and the XOR bit as a black/white pixel. +func decodeMonoCursor(hbmMask windows.Handle) (*image.RGBA, error) { + bm, err := readBitmap(hbmMask) + if err != nil { + return nil, err + } + w, fullH := bm.BmWidth, bm.BmHeight + if fullH%2 != 0 { + return nil, fmt.Errorf("unexpected mono cursor shape: %dx%d", w, fullH) + } + h := fullH / 2 + data, err := dibCopy(hbmMask, w, fullH) + if err != nil { + return nil, err + } + img := image.NewRGBA(image.Rect(0, 0, int(w), int(h))) + for y := int32(0); y < h; y++ { + for x := int32(0); x < w; x++ { + and := data[(y*w+x)*4] + xor := data[((y+h)*w+x)*4] + di := (y*w + x) * 4 + if and != 0 { + img.Pix[di+3] = 0 + continue + } + c := byte(0) + if xor != 0 { + c = 255 + } + img.Pix[di+0] = c + img.Pix[di+1] = c + img.Pix[di+2] = c + img.Pix[di+3] = 255 + } + } + return img, nil +} + +// cursorState is the latest snapshot shared between the worker and +// session readers. +type cursorState struct { + mu sync.Mutex + snapshot *cursorSnapshot +} + +func (s *cursorState) store(snap *cursorSnapshot) { + s.mu.Lock() + s.snapshot = snap + s.mu.Unlock() +} + +func (s *cursorState) load() *cursorSnapshot { + s.mu.Lock() + snap := s.snapshot + s.mu.Unlock() + return snap +} + +// Cursor satisfies cursorSource by returning the latest snapshot the +// capture worker decoded. The "no sample yet" and "cursor hidden" cases +// return img=nil with no error so callers skip emission this cycle +// without latching the source off for the rest of the session. +func (c *DesktopCapturer) Cursor() (*image.RGBA, int, int, uint64, error) { + snap := c.cursorState.load() + if snap == nil { + return nil, 0, 0, 0, nil + } + if snap.err != nil { + return nil, 0, 0, 0, snap.err + } + return snap.img, snap.hotX, snap.hotY, snap.serial, nil +} diff --git a/client/vnc/server/cursor_x11.go b/client/vnc/server/cursor_x11.go new file mode 100644 index 00000000000..5dd06f5c56c --- /dev/null +++ b/client/vnc/server/cursor_x11.go @@ -0,0 +1,87 @@ +//go:build unix && !darwin && !ios && !android + +package server + +import ( + "fmt" + "image" + "sync" + + "github.com/jezek/xgb" + "github.com/jezek/xgb/xfixes" +) + +// xfixesCursor reports the current X cursor sprite via the XFixes extension. +// CursorSerial changes whenever the server picks a different cursor, so +// callers can cache by serial without comparing pixels. +type xfixesCursor struct { + mu sync.Mutex + conn *xgb.Conn + // runtimeErr latches the first GetCursorImage failure so subsequent + // calls return quickly without another X round-trip. Some virtual + // displays advertise XFixes but reject GetCursorImage (Xvfb). + runtimeErr error +} + +// newXFixesCursor initialises the XFixes extension on conn. Returns an +// error if the extension is unavailable; callers can fall back to no +// cursor emission instead of asking on every frame. +func newXFixesCursor(conn *xgb.Conn) (*xfixesCursor, error) { + if err := xfixes.Init(conn); err != nil { + return nil, fmt.Errorf("xfixes init: %w", err) + } + if _, err := xfixes.QueryVersion(conn, 4, 0).Reply(); err != nil { + return nil, fmt.Errorf("xfixes query version: %w", err) + } + return &xfixesCursor{conn: conn}, nil +} + +// Cursor returns the current cursor sprite as RGBA along with its hotspot +// and serial. Callers should treat an unchanged serial as "no update". +func (c *xfixesCursor) Cursor() (*image.RGBA, int, int, uint64, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.runtimeErr != nil { + return nil, 0, 0, 0, c.runtimeErr + } + reply, err := xfixes.GetCursorImage(c.conn).Reply() + if err != nil { + c.runtimeErr = fmt.Errorf("xfixes GetCursorImage: %w", err) + return nil, 0, 0, 0, c.runtimeErr + } + w, h := int(reply.Width), int(reply.Height) + if w <= 0 || h <= 0 { + return nil, 0, 0, 0, fmt.Errorf("cursor has zero extent") + } + if len(reply.CursorImage) < w*h { + return nil, 0, 0, 0, fmt.Errorf("cursor pixel buffer truncated: %d < %d", len(reply.CursorImage), w*h) + } + img := image.NewRGBA(image.Rect(0, 0, w, h)) + // XFixes packs each pixel as a uint32 in ARGB order with premultiplied + // alpha. Unpack into the standard RGBA byte layout. + for i, p := range reply.CursorImage[:w*h] { + o := i * 4 + img.Pix[o+0] = byte(p >> 16) + img.Pix[o+1] = byte(p >> 8) + img.Pix[o+2] = byte(p) + img.Pix[o+3] = byte(p >> 24) + } + return img, int(reply.Xhot), int(reply.Yhot), uint64(reply.CursorSerial), nil +} + +// Cursor on X11Capturer satisfies cursorSource. The XFixes binding is +// created lazily on the same X connection used for screen capture; the +// first init failure is latched so we stop asking on every frame. +func (x *X11Capturer) Cursor() (*image.RGBA, int, int, uint64, error) { + x.mu.Lock() + if x.cursor == nil && x.cursorInitErr == nil { + x.cursor, x.cursorInitErr = newXFixesCursor(x.conn) + } + cur := x.cursor + initErr := x.cursorInitErr + x.mu.Unlock() + if initErr != nil { + return nil, 0, 0, 0, initErr + } + return cur.Cursor() +} diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 11a288eb3cb..e8e6e11c2d4 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -63,6 +63,7 @@ const ( // Pseudo-encodings carried over wire as rects with a negative // encoding value. The client advertises supported optional protocol // extensions by listing these in SetEncodings. + pseudoEncCursor = -239 pseudoEncDesktopSize = -223 pseudoEncLastRect = -224 pseudoEncQEMUExtendedKeyEvent = -258 diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 0cbd951826a..1e6a21bac7e 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -72,6 +72,13 @@ type captureIntoer interface { CaptureInto(dst *image.RGBA) error } +// cursorSource is implemented by capturers that can report the platform +// cursor sprite so the session can emit it via the Cursor pseudo-encoding +// (RFB 7.7.4). serial bumps on shape changes; callers cache by serial. +type cursorSource interface { + Cursor() (img *image.RGBA, hotX, hotY int, serial uint64, err error) +} + // errFrameUnchanged is returned by capturers that hash the raw source // bytes (currently macOS) when the new frame is byte-identical to the // last one, so the encoder can short-circuit to an empty update. @@ -556,6 +563,10 @@ func (s *Server) handleConnection(conn net.Conn) { serverW: capturer.Width(), serverH: capturer.Height(), log: connLog, + // Virtual sessions run on Xvfb which has no usable cursor source, + // so we skip the Cursor pseudo-encoding and let the dashboard's + // local fallback show instead. + disableCursor: header.mode == ModeSession, } sess.serve() } diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 0228701c626..ae138f135bc 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -78,7 +78,20 @@ type session struct { clientSupportsLastRect bool clientSupportsQEMUKey bool clientSupportsExtClipboard bool + clientSupportsCursor bool extClipCapsSent bool + // lastCursorSerial is the serial of the cursor sprite last emitted. + // The encoder re-queries the source each cycle and only emits when + // the serial changes. + lastCursorSerial uint64 + // cursorSourceFailed latches a permanent failure from the cursor + // source so the encoder stops polling for the rest of the session. + // Reset on SetEncodings so a reconnect can retry. + cursorSourceFailed bool + // disableCursor suppresses the Cursor pseudo-encoding regardless of + // what the client advertises. Set for virtual sessions where no + // usable cursor source exists. Constant for the session lifetime. + disableCursor bool // clientJPEGQuality and clientZlibLevel hold the 0..9 levels the client // advertised via the QualityLevel / CompressLevel pseudo-encodings, or // -1 when the client has not expressed a preference. Applied to the @@ -362,6 +375,8 @@ func (s *session) resetEncodingCaps() { s.clientSupportsLastRect = false s.clientSupportsQEMUKey = false s.clientSupportsExtClipboard = false + s.clientSupportsCursor = false + s.cursorSourceFailed = false s.clientJPEGQuality = -1 s.clientZlibLevel = -1 } @@ -395,6 +410,12 @@ func (s *session) applyEncoding(enc int32) string { case pseudoEncExtendedClipboard: s.clientSupportsExtClipboard = true return "ext-clipboard" + case pseudoEncCursor: + if s.disableCursor { + return "" + } + s.clientSupportsCursor = true + return "cursor" case encTight: s.useTight = true return "tight" diff --git a/client/vnc/server/session_cursor.go b/client/vnc/server/session_cursor.go new file mode 100644 index 00000000000..bf3765adc8b --- /dev/null +++ b/client/vnc/server/session_cursor.go @@ -0,0 +1,87 @@ +//go:build !js && !ios && !android + +package server + +import ( + "encoding/binary" + "image" +) + +// pendingCursorRect returns the Cursor pseudo-rect for the current sprite +// when the client negotiated the encoding and the platform exposes a +// cursor source whose serial has changed since the last emission. A nil +// return means "do not include a cursor rect in this FramebufferUpdate". +func (s *session) pendingCursorRect() []byte { + s.encMu.RLock() + supported := s.clientSupportsCursor + failed := s.cursorSourceFailed + lastSerial := s.lastCursorSerial + s.encMu.RUnlock() + if !supported || failed { + return nil + } + src, ok := s.capturer.(cursorSource) + if !ok { + return nil + } + img, hotX, hotY, serial, err := src.Cursor() + if err != nil { + s.encMu.Lock() + s.cursorSourceFailed = true + s.encMu.Unlock() + s.log.Debugf("cursor source unavailable: %v", err) + return nil + } + if img == nil || serial == lastSerial { + return nil + } + buf := encodeCursorPseudoRect(img, hotX, hotY) + s.encMu.Lock() + s.lastCursorSerial = serial + s.encMu.Unlock() + return buf +} + +// encodeCursorPseudoRect packs the cursor sprite into a Cursor pseudo +// rectangle (RFB 7.7.4, pseudo-encoding -239). Layout: 12-byte rect header +// followed by w*h*4 BGRX pixel bytes and a 1-bit mask of (w+7)/8 bytes per +// row, MSB-first, with each row independently padded. +func encodeCursorPseudoRect(img *image.RGBA, hotX, hotY int) []byte { + w, h := img.Rect.Dx(), img.Rect.Dy() + pixelBytes := w * h * 4 + maskStride := (w + 7) / 8 + maskBytes := maskStride * h + buf := make([]byte, 12+pixelBytes+maskBytes) + + binary.BigEndian.PutUint16(buf[0:2], uint16(hotX)) + binary.BigEndian.PutUint16(buf[2:4], uint16(hotY)) + binary.BigEndian.PutUint16(buf[4:6], uint16(w)) + binary.BigEndian.PutUint16(buf[6:8], uint16(h)) + enc := int32(pseudoEncCursor) + binary.BigEndian.PutUint32(buf[8:12], uint32(enc)) + + pix := buf[12 : 12+pixelBytes] + mask := buf[12+pixelBytes:] + src := img.Pix + stride := img.Stride + for y := 0; y < h; y++ { + row := y * stride + dstRow := y * w * 4 + maskRow := y * maskStride + for x := 0; x < w; x++ { + r := src[row+x*4+0] + g := src[row+x*4+1] + b := src[row+x*4+2] + a := src[row+x*4+3] + off := dstRow + x*4 + pix[off+0] = b + pix[off+1] = g + pix[off+2] = r + pix[off+3] = 0 + if a >= 0x80 { + mask[maskRow+x/8] |= 0x80 >> (x % 8) + } + } + } + return buf +} diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 4d650159135..2197bf8bcaa 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -377,14 +377,22 @@ func (s *session) swapPrevCur() { s.prevFrame, s.curFrame = s.curFrame, s.prevFrame } -// sendEmptyUpdate sends a FramebufferUpdate with zero rectangles. +// sendEmptyUpdate sends a FramebufferUpdate with zero pixel rectangles. +// When the cursor source reports a fresh sprite we still slip the Cursor +// pseudo-rect into the same message so a shape change (e.g. hovering onto +// a resize handle) reaches the client without waiting for a dirty frame. func (s *session) sendEmptyUpdate() error { - var buf [4]byte + cursorRect := s.pendingCursorRect() + if cursorRect == nil { + var buf [4]byte + buf[0] = serverFramebufferUpdate + return s.writeFramed(buf[:]) + } + buf := make([]byte, 4+len(cursorRect)) buf[0] = serverFramebufferUpdate - s.writeMu.Lock() - _, err := s.conn.Write(buf[:]) - s.writeMu.Unlock() - return err + binary.BigEndian.PutUint16(buf[2:4], 1) + copy(buf[4:], cursorRect) + return s.writeFramed(buf) } func (s *session) sendFullUpdate(img *image.RGBA) error { @@ -398,27 +406,40 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { zlib := s.zlib s.encMu.RUnlock() - if useTight && tight != nil && pfIsTightCompatible(pf) { - rectBuf := encodeTightRect(img, pf, 0, 0, w, h, tight) - buf := make([]byte, 4+len(rectBuf)) - buf[0] = serverFramebufferUpdate - binary.BigEndian.PutUint16(buf[2:4], 1) - copy(buf[4:], rectBuf) - s.writeMu.Lock() - _, err := s.conn.Write(buf) - s.writeMu.Unlock() - return err + cursorRect := s.pendingCursorRect() + rectCount := uint16(1) + if cursorRect != nil { + rectCount++ + } + + var rectBuf []byte + switch { + case useTight && tight != nil && pfIsTightCompatible(pf): + rectBuf = encodeTightRect(img, pf, 0, 0, w, h, tight) + case useZlib && zlib != nil: + // encodeZlibRect bakes in its own FBU header; reuse it for the + // single-rect path when there is no cursor to prepend. + if cursorRect == nil { + return s.writeFramed(encodeZlibRect(img, pf, 0, 0, w, h, zlib)) + } + rectBuf = encodeZlibRect(img, pf, 0, 0, w, h, zlib)[4:] + default: + if cursorRect == nil { + return s.writeFramed(encodeRawRect(img, pf, 0, 0, w, h)) + } + rectBuf = encodeRawRect(img, pf, 0, 0, w, h)[4:] } - if useZlib && zlib != nil { - buf := encodeZlibRect(img, pf, 0, 0, w, h, zlib) - s.writeMu.Lock() - _, err := s.conn.Write(buf) - s.writeMu.Unlock() - return err - } + buf := make([]byte, 4+len(cursorRect)+len(rectBuf)) + buf[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(buf[2:4], rectCount) + off := 4 + off += copy(buf[off:], cursorRect) + copy(buf[off:], rectBuf) + return s.writeFramed(buf) +} - buf := encodeRawRect(img, pf, 0, 0, w, h) +func (s *session) writeFramed(buf []byte) error { s.writeMu.Lock() _, err := s.conn.Write(buf) s.writeMu.Unlock() @@ -430,11 +451,15 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { // their source tiles are read from the client's pre-update framebuffer state, // before any subsequent rect overwrites them. func (s *session) sendDirtyAndMoves(img *image.RGBA, moves []copyRectMove, rects [][4]int) error { - if len(moves) == 0 && len(rects) == 0 { + cursorRect := s.pendingCursorRect() + if len(moves) == 0 && len(rects) == 0 && cursorRect == nil { return nil } total := len(moves) + len(rects) + if cursorRect != nil { + total++ + } header := make([]byte, 4) header[0] = serverFramebufferUpdate binary.BigEndian.PutUint16(header[2:4], uint16(total)) @@ -446,6 +471,12 @@ func (s *session) sendDirtyAndMoves(img *image.RGBA, moves []copyRectMove, rects return err } + if cursorRect != nil { + if _, err := s.conn.Write(cursorRect); err != nil { + return err + } + } + ts := tileSize for _, m := range moves { body := encodeCopyRectBody(m.srcX, m.srcY, m.dstX, m.dstY, ts, ts) From b1b04f9ec6fc87ad02fd9fbabfc757d534f597ca Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 14:40:15 +0200 Subject: [PATCH 051/151] Composite remote cursor into the framebuffer when the dashboard toggles it on --- client/vnc/server/capture_darwin.go | 18 ++++ client/vnc/server/capture_x11.go | 10 ++ client/vnc/server/cursor_darwin.go | 25 +++++ client/vnc/server/cursor_windows.go | 44 +++++++- client/vnc/server/cursor_x11.go | 24 +++++ client/vnc/server/rfb.go | 8 ++ client/vnc/server/server.go | 8 ++ client/vnc/server/session.go | 11 ++ client/vnc/server/session_cursor.go | 3 +- client/vnc/server/session_encode.go | 2 + client/vnc/server/session_remote_cursor.go | 119 +++++++++++++++++++++ 11 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 client/vnc/server/session_remote_cursor.go diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index 2144fd96cda..8f345ddc1a4 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -38,9 +38,18 @@ var ( cfRelease func(uintptr) cgPreflightScreenCaptureAccess func() bool cgRequestScreenCaptureAccess func() bool + cgEventCreate func(uintptr) uintptr + cgEventGetLocation func(uintptr) cgPoint darwinCaptureReady bool ) +// cgPoint mirrors CoreGraphics CGPoint: two doubles, 16 bytes, returned +// in registers on Darwin amd64/arm64. Used to receive cursor coordinates +// from CGEventGetLocation via purego. +type cgPoint struct { + X, Y float64 +} + func initDarwinCapture() { darwinCaptureOnce.Do(func() { cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL) @@ -76,6 +85,15 @@ func initDarwinCapture() { if sym, err := purego.Dlsym(cg, "CGRequestScreenCaptureAccess"); err == nil { purego.RegisterFunc(&cgRequestScreenCaptureAccess, sym) } + // CGEventCreate / CGEventGetLocation feed the cursor position used + // by remote-cursor compositing. Optional; absence reports as a + // position-source error and disables that feature on this host. + if sym, err := purego.Dlsym(cg, "CGEventCreate"); err == nil { + purego.RegisterFunc(&cgEventCreate, sym) + } + if sym, err := purego.Dlsym(cg, "CGEventGetLocation"); err == nil { + purego.RegisterFunc(&cgEventGetLocation, sym) + } darwinCaptureReady = true }) diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index 804c1f02c9f..93ee84ae1e4 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -425,6 +425,16 @@ func (p *X11Poller) Cursor() (*image.RGBA, int, int, uint64, error) { return p.capturer.Cursor() } +// CursorPos satisfies cursorPositionSource by forwarding to the X11Capturer. +func (p *X11Poller) CursorPos() (int, int, error) { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ensureCapturerLocked(); err != nil { + return 0, 0, err + } + return p.capturer.CursorPos() +} + // Capture returns a fresh frame, serving from the short-lived cache if a // previous caller captured within freshWindow. func (p *X11Poller) Capture() (*image.RGBA, error) { diff --git a/client/vnc/server/cursor_darwin.go b/client/vnc/server/cursor_darwin.go index 29b85663089..324c38b79b9 100644 --- a/client/vnc/server/cursor_darwin.go +++ b/client/vnc/server/cursor_darwin.go @@ -156,6 +156,21 @@ func (c *CGCapturer) Cursor() (*image.RGBA, int, int, uint64, error) { return c.cursor.Cursor() } +// CursorPos returns the current global mouse location via CGEventCreate / +// CGEventGetLocation. Coordinates are screen pixels in the main display. +func (c *CGCapturer) CursorPos() (int, int, error) { + if cgEventCreate == nil || cgEventGetLocation == nil { + return 0, 0, fmt.Errorf("CGEvent location APIs unavailable") + } + ev := cgEventCreate(0) + if ev == 0 { + return 0, 0, fmt.Errorf("CGEventCreate returned nil") + } + defer cfRelease(ev) + pt := cgEventGetLocation(ev) + return int(pt.X), int(pt.Y), nil +} + // Cursor on MacPoller forwards to the lazy CGCapturer. ensureCapturerLocked // returns an error when Screen Recording permission has not been granted; // in that case there is no usable cursor source either. @@ -167,3 +182,13 @@ func (p *MacPoller) Cursor() (*image.RGBA, int, int, uint64, error) { } return p.capturer.Cursor() } + +// CursorPos forwards to the lazy CGCapturer. +func (p *MacPoller) CursorPos() (int, int, error) { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ensureCapturerLocked(); err != nil { + return 0, 0, err + } + return p.capturer.CursorPos() +} diff --git a/client/vnc/server/cursor_windows.go b/client/vnc/server/cursor_windows.go index 6b76638b418..85e34d833a1 100644 --- a/client/vnc/server/cursor_windows.go +++ b/client/vnc/server/cursor_windows.go @@ -86,6 +86,9 @@ type cursorSnapshot struct { img *image.RGBA hotX int hotY int + posX int + posY int + hasPos bool serial uint64 err error } @@ -118,14 +121,26 @@ func (s *cursorSampler) sample() (*cursorSnapshot, error) { // treating this as a hard failure that would latch us off for // the session. if s.lastHandle == hiddenHandle { + s.snapshot.posX = int(ci.PtPos.X) + s.snapshot.posY = int(ci.PtPos.Y) + s.snapshot.hasPos = true return s.snapshot, nil } s.lastHandle = hiddenHandle s.serial++ - s.snapshot = &cursorSnapshot{img: transparentCursorImage(), serial: s.serial} + s.snapshot = &cursorSnapshot{ + img: transparentCursorImage(), + posX: int(ci.PtPos.X), + posY: int(ci.PtPos.Y), + hasPos: true, + serial: s.serial, + } return s.snapshot, nil } if ci.Cursor == s.lastHandle && s.snapshot != nil { + s.snapshot.posX = int(ci.PtPos.X) + s.snapshot.posY = int(ci.PtPos.Y) + s.snapshot.hasPos = true return s.snapshot, nil } img, hotX, hotY, err := decodeCursor(ci.Cursor) @@ -134,7 +149,15 @@ func (s *cursorSampler) sample() (*cursorSnapshot, error) { } s.lastHandle = ci.Cursor s.serial++ - s.snapshot = &cursorSnapshot{img: img, hotX: hotX, hotY: hotY, serial: s.serial} + s.snapshot = &cursorSnapshot{ + img: img, + hotX: hotX, + hotY: hotY, + posX: int(ci.PtPos.X), + posY: int(ci.PtPos.Y), + hasPos: true, + serial: s.serial, + } return s.snapshot, nil } @@ -348,3 +371,20 @@ func (c *DesktopCapturer) Cursor() (*image.RGBA, int, int, uint64, error) { } return snap.img, snap.hotX, snap.hotY, snap.serial, nil } + +// CursorPos returns the cursor screen position observed by the worker on +// its last sample. Errors out if the worker hasn't yet captured a frame +// or the most recent sample failed. +func (c *DesktopCapturer) CursorPos() (int, int, error) { + snap := c.cursorState.load() + if snap == nil { + return 0, 0, fmt.Errorf("cursor position not sampled yet") + } + if snap.err != nil { + return 0, 0, snap.err + } + if !snap.hasPos { + return 0, 0, fmt.Errorf("cursor position unavailable") + } + return snap.posX, snap.posY, nil +} diff --git a/client/vnc/server/cursor_x11.go b/client/vnc/server/cursor_x11.go index 5dd06f5c56c..2ac8d27984b 100644 --- a/client/vnc/server/cursor_x11.go +++ b/client/vnc/server/cursor_x11.go @@ -21,6 +21,11 @@ type xfixesCursor struct { // calls return quickly without another X round-trip. Some virtual // displays advertise XFixes but reject GetCursorImage (Xvfb). runtimeErr error + // lastPosX/lastPosY hold the cursor screen position observed on the + // most recent successful GetCursorImage. cursorPositionSource readers + // share this value so we do not pay a second X round-trip per frame. + lastPosX, lastPosY int + hasPos bool } // newXFixesCursor initialises the XFixes extension on conn. Returns an @@ -49,6 +54,7 @@ func (c *xfixesCursor) Cursor() (*image.RGBA, int, int, uint64, error) { c.runtimeErr = fmt.Errorf("xfixes GetCursorImage: %w", err) return nil, 0, 0, 0, c.runtimeErr } + c.lastPosX, c.lastPosY, c.hasPos = int(reply.X), int(reply.Y), true w, h := int(reply.Width), int(reply.Height) if w <= 0 || h <= 0 { return nil, 0, 0, 0, fmt.Errorf("cursor has zero extent") @@ -85,3 +91,21 @@ func (x *X11Capturer) Cursor() (*image.RGBA, int, int, uint64, error) { } return cur.Cursor() } + +// CursorPos on X11Capturer returns the screen position from the most +// recent successful Cursor() call. Sessions call Cursor() once per encode +// cycle, so this stays current without a second X round-trip. +func (x *X11Capturer) CursorPos() (int, int, error) { + x.mu.Lock() + cur := x.cursor + x.mu.Unlock() + if cur == nil { + return 0, 0, fmt.Errorf("cursor source not initialised") + } + cur.mu.Lock() + defer cur.mu.Unlock() + if !cur.hasPos { + return 0, 0, fmt.Errorf("cursor position not sampled yet") + } + return cur.lastPosX, cur.lastPosY, nil +} diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index e8e6e11c2d4..291d3529aec 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -49,6 +49,14 @@ const ( // The opcode is in the vendor-specific range (>=128). clientNetbirdTypeText = 250 + // clientNetbirdShowRemoteCursor toggles "show remote cursor" mode. + // When enabled the encoder composites the server cursor sprite into + // the captured framebuffer and suppresses the Cursor pseudo-encoding + // so the dashboard sees a single pointer at the remote position. + // Wire format: 1-byte msgType + 1-byte enable flag + 6 padding bytes + // reserved for future arguments (so the message is fixed-size). + clientNetbirdShowRemoteCursor = 251 + // Server message types. serverFramebufferUpdate = 0 serverCutText = 3 diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 1e6a21bac7e..0768250744c 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -79,6 +79,14 @@ type cursorSource interface { Cursor() (img *image.RGBA, hotX, hotY int, serial uint64, err error) } +// cursorPositionSource adds the cursor's current screen-space position to +// cursorSource so the encoder can alpha-blend the sprite into the captured +// framebuffer for "show remote cursor" mode. Implementations should be +// cheap; most platforms already get the position alongside the sprite. +type cursorPositionSource interface { + CursorPos() (x, y int, err error) +} + // errFrameUnchanged is returned by capturers that hash the raw source // bytes (currently macOS) when the new frame is byte-identical to the // last one, so the encoder can short-circuit to an empty update. diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index ae138f135bc..18eb4461da1 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -92,6 +92,15 @@ type session struct { // what the client advertises. Set for virtual sessions where no // usable cursor source exists. Constant for the session lifetime. disableCursor bool + // showRemoteCursor switches the encoder to compositing the server + // cursor sprite into the captured framebuffer at the remote position + // instead of emitting the Cursor pseudo-encoding. Toggled by the + // dashboard via clientNetbirdShowRemoteCursor. + showRemoteCursor bool + // cursorWarnOnce throttles the diagnostic emitted when remote-cursor + // compositing falls back to a no-op (capturer cannot supply a sprite + // or position). One line per session is enough to point at the cause. + cursorWarnOnce sync.Once // clientJPEGQuality and clientZlibLevel hold the 0..9 levels the client // advertised via the QualityLevel / CompressLevel pseudo-encodings, or // -1 when the client has not expressed a preference. Applied to the @@ -274,6 +283,8 @@ func (s *session) messageLoop() error { err = s.handleQEMUMessage() case clientNetbirdTypeText: err = s.handleTypeText() + case clientNetbirdShowRemoteCursor: + err = s.handleShowRemoteCursor() default: return fmt.Errorf("unknown client message type: %d", msgType[0]) } diff --git a/client/vnc/server/session_cursor.go b/client/vnc/server/session_cursor.go index bf3765adc8b..2ab62ea2732 100644 --- a/client/vnc/server/session_cursor.go +++ b/client/vnc/server/session_cursor.go @@ -15,9 +15,10 @@ func (s *session) pendingCursorRect() []byte { s.encMu.RLock() supported := s.clientSupportsCursor failed := s.cursorSourceFailed + composite := s.showRemoteCursor lastSerial := s.lastCursorSerial s.encMu.RUnlock() - if !supported || failed { + if !supported || failed || composite { return nil } src, ok := s.capturer.(cursorSource) diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 2197bf8bcaa..8068bb1d7dc 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -67,6 +67,8 @@ func (s *session) processFBRequest(req fbRequest) error { } s.captureRecovered() + s.maybeCompositeCursor(img) + if req.incremental && s.prevFrame != nil { return s.processIncremental(img) } diff --git a/client/vnc/server/session_remote_cursor.go b/client/vnc/server/session_remote_cursor.go new file mode 100644 index 00000000000..2ed77320c10 --- /dev/null +++ b/client/vnc/server/session_remote_cursor.go @@ -0,0 +1,119 @@ +//go:build !js && !ios && !android + +package server + +import ( + "fmt" + "image" + "io" +) + +// handleShowRemoteCursor handles the NetBird-specific RFB message used by +// the dashboard to toggle "show remote cursor" mode. Wire format: 1-byte +// enable flag (0/1) plus 6 padding bytes reserved for future arguments. +func (s *session) handleShowRemoteCursor() error { + var data [7]byte + if _, err := io.ReadFull(s.conn, data[:]); err != nil { + return fmt.Errorf("read showRemoteCursor: %w", err) + } + enable := data[0] != 0 + s.encMu.Lock() + s.showRemoteCursor = enable + s.encMu.Unlock() + s.log.Debugf("show remote cursor: %v", enable) + return nil +} + +// maybeCompositeCursor blends the current server cursor into img when the +// dashboard has enabled "show remote cursor" mode. Returns silently in +// every error path: a failed compositing must not stop the regular encode +// flow. +func (s *session) maybeCompositeCursor(img *image.RGBA) { + s.encMu.RLock() + enabled := s.showRemoteCursor + s.encMu.RUnlock() + if !enabled || img == nil { + return + } + src, ok := s.capturer.(cursorSource) + if !ok { + return + } + pos, ok := s.capturer.(cursorPositionSource) + if !ok { + return + } + cursorImg, hotX, hotY, _, err := src.Cursor() + if err != nil || cursorImg == nil { + s.cursorWarnOnce.Do(func() { + s.log.Warnf("remote cursor unavailable: %v", err) + }) + return + } + posX, posY, err := pos.CursorPos() + if err != nil { + s.cursorWarnOnce.Do(func() { + s.log.Warnf("remote cursor position unavailable: %v", err) + }) + return + } + compositeCursor(img, cursorImg, posX-hotX, posY-hotY) +} + +// compositeCursor alpha-blends sprite onto frame at (dstX, dstY) using +// straight (non-premultiplied) alpha. Out-of-bounds destinations are +// clipped. Frames captured by our X11/Windows/macOS paths all advertise +// RGBA with a 255-only alpha channel, so the result keeps the framebuffer +// invariant ("opaque pixels everywhere") that the encoder depends on. +func compositeCursor(frame, sprite *image.RGBA, dstX, dstY int) { + fw, fh := frame.Rect.Dx(), frame.Rect.Dy() + sw, sh := sprite.Rect.Dx(), sprite.Rect.Dy() + if sw == 0 || sh == 0 { + return + } + + x0, y0 := dstX, dstY + x1, y1 := dstX+sw, dstY+sh + if x0 < 0 { + x0 = 0 + } + if y0 < 0 { + y0 = 0 + } + if x1 > fw { + x1 = fw + } + if y1 > fh { + y1 = fh + } + if x0 >= x1 || y0 >= y1 { + return + } + + fStride := frame.Stride + sStride := sprite.Stride + for y := y0; y < y1; y++ { + sy := y - dstY + fbRow := y * fStride + sRow := sy * sStride + for x := x0; x < x1; x++ { + sx := x - dstX + fbOff := fbRow + x*4 + sOff := sRow + sx*4 + a := uint32(sprite.Pix[sOff+3]) + if a == 0 { + continue + } + if a == 255 { + frame.Pix[fbOff+0] = sprite.Pix[sOff+0] + frame.Pix[fbOff+1] = sprite.Pix[sOff+1] + frame.Pix[fbOff+2] = sprite.Pix[sOff+2] + continue + } + inv := 255 - a + frame.Pix[fbOff+0] = byte((uint32(sprite.Pix[sOff+0])*a + uint32(frame.Pix[fbOff+0])*inv) / 255) + frame.Pix[fbOff+1] = byte((uint32(sprite.Pix[sOff+1])*a + uint32(frame.Pix[fbOff+1])*inv) / 255) + frame.Pix[fbOff+2] = byte((uint32(sprite.Pix[sOff+2])*a + uint32(frame.Pix[fbOff+2])*inv) / 255) + } + } +} From df9a6fb0206e3853b7cea66240d0f576167a88e3 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 15:24:45 +0200 Subject: [PATCH 052/151] Drop pbpaste trace log that fires whenever the macOS pasteboard is empty --- client/vnc/server/input_darwin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 1bda2285c30..5b51b578ef1 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -564,7 +564,7 @@ func (m *MacInputInjector) GetClipboard() string { } out, err := exec.Command(m.pbpastePath).Output() if err != nil { - log.Tracef("get clipboard via pbpaste: %v", err) + // pbpaste exits 1 when the pasteboard has no string flavour. return "" } return string(out) From 62b36112ea5863a6d5798a650f364766fe63732e Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 16:53:59 +0200 Subject: [PATCH 053/151] Extract daemon-to-agent loopback proxy and token helpers into a platform-neutral file --- client/cmd/vnc_agent.go | 16 ++--- client/vnc/server/agent_ipc.go | 90 +++++++++++++++++++++++++++++ client/vnc/server/agent_windows.go | 84 +++------------------------ client/vnc/server/server_windows.go | 2 +- 4 files changed, 105 insertions(+), 87 deletions(-) create mode 100644 client/vnc/server/agent_ipc.go diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go index a6a583ac7ed..c5b036b852c 100644 --- a/client/cmd/vnc_agent.go +++ b/client/cmd/vnc_agent.go @@ -13,10 +13,10 @@ import ( vncserver "github.com/netbirdio/netbird/client/vnc/server" ) -var vncAgentPort string +var vncAgentPort uint16 func init() { - vncAgentCmd.Flags().StringVar(&vncAgentPort, "port", "15900", "Port for the VNC agent to listen on") + vncAgentCmd.Flags().Uint16Var(&vncAgentPort, "port", 15900, "Port for the VNC agent to listen on") rootCmd.AddCommand(vncAgentCmd) } @@ -35,7 +35,7 @@ var vncAgentCmd = &cobra.Command{ log.SetOutput(os.Stderr) sessionID := vncserver.GetCurrentSessionID() - log.Infof("VNC agent starting on 127.0.0.1:%s (session %d)", vncAgentPort, sessionID) + log.Infof("VNC agent starting on 127.0.0.1:%d (session %d)", vncAgentPort, sessionID) token := os.Getenv("NB_VNC_AGENT_TOKEN") if token == "" { @@ -48,16 +48,12 @@ var vncAgentCmd = &cobra.Command{ srv.SetDisableAuth(true) srv.SetAgentToken(token) - port, err := netip.ParseAddrPort("127.0.0.1:" + vncAgentPort) - if err != nil { - return fmt.Errorf("parse listen addr: %w", err) - } - + addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), vncAgentPort) loopback := netip.PrefixFrom(netip.AddrFrom4([4]byte{127, 0, 0, 0}), 8) - if err := srv.Start(cmd.Context(), port, loopback); err != nil { + if err := srv.Start(cmd.Context(), addr, loopback); err != nil { return fmt.Errorf("start vnc server: %w", err) } - log.Infof("vnc-agent listening on 127.0.0.1:%s, ready", vncAgentPort) + log.Infof("vnc-agent listening on 127.0.0.1:%d, ready", vncAgentPort) <-cmd.Context().Done() log.Info("vnc-agent context cancelled, shutting down") diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go new file mode 100644 index 00000000000..e0124ee3aad --- /dev/null +++ b/client/vnc/server/agent_ipc.go @@ -0,0 +1,90 @@ +//go:build !js && !ios && !android + +package server + +import ( + crand "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + // agentPort is the TCP loopback port on which a per-session VNC agent + // listens. The daemon dials this port and presents agentToken before + // proxying VNC bytes. The choice of TCP (rather than a Unix socket or + // named pipe) is intentional: it lets the same proxy/handshake code + // run on every platform; the token does the access control. + agentPort uint16 = 15900 + + // agentTokenLen is the size of the random per-spawn token in bytes. + agentTokenLen = 32 +) + +// generateAuthToken returns a fresh hex-encoded random token for one +// daemon→agent session. The daemon hands this to the spawned agent +// out-of-band (env var on Windows) and verifies it on every connection +// the agent accepts. Returns the empty string on a randomness failure; +// callers should treat that as an error. +func generateAuthToken() string { + b := make([]byte, agentTokenLen) + if _, err := crand.Read(b); err != nil { + log.Warnf("generate agent auth token: %v", err) + return "" + } + return hex.EncodeToString(b) +} + +// proxyToAgent dials the per-session agent on TCP loopback, writes the +// raw token bytes, and then copies bytes in both directions until either +// side closes. The token has to land on the wire before any VNC byte so +// the agent's listening Server can apply verifyAgentToken before letting +// real RFB traffic through. +func proxyToAgent(client net.Conn, port uint16, authToken string) { + defer client.Close() + + addr := fmt.Sprintf("127.0.0.1:%d", port) + agentConn, err := dialAgentWithRetry(addr) + if err != nil { + log.Warnf("proxy cannot reach agent at %s: %v", addr, err) + return + } + defer agentConn.Close() + + tokenBytes, _ := hex.DecodeString(authToken) + if _, err := agentConn.Write(tokenBytes); err != nil { + log.Warnf("send auth token to agent: %v", err) + return + } + + log.Debugf("proxy connected to agent, starting bidirectional copy") + done := make(chan struct{}, 2) + cp := func(label string, dst, src net.Conn) { + n, err := io.Copy(dst, src) + log.Debugf("proxy %s: %d bytes, err=%v", label, n, err) + done <- struct{}{} + } + go cp("client→agent", agentConn, client) + go cp("agent→client", client, agentConn) + <-done +} + +// dialAgentWithRetry retries the loopback connect for up to ~10 s so the +// daemon does not race the agent's first listen. Returns the live conn or +// the final error. +func dialAgentWithRetry(addr string) (net.Conn, error) { + var lastErr error + for range 50 { + c, err := net.DialTimeout("tcp", addr, time.Second) + if err == nil { + return c, nil + } + lastErr = err + time.Sleep(200 * time.Millisecond) + } + return nil, lastErr +} diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 6a777b442ec..4bacbdd6f78 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -4,17 +4,12 @@ package server import ( "bufio" - crand "crypto/rand" "encoding/binary" - "encoding/hex" "encoding/json" "errors" "fmt" - "io" - "net" "os" "runtime" - "strconv" "strings" "sync" "time" @@ -25,12 +20,6 @@ import ( ) const ( - agentPort = "15900" - - // agentTokenLen is the length of the random authentication token - // used to verify that connections to the agent come from the service. - agentTokenLen = 32 - stillActive = 259 tokenPrimary = 1 @@ -151,16 +140,11 @@ func getActiveSessionID() uint32 { return getConsoleSessionID() } -// reapOrphanOnPort finds any process listening on 127.0.0.1:portStr and, -// if it's a netbird vnc-agent left over from a previous service instance, +// reapOrphanOnPort finds any process listening on 127.0.0.1:port and, if +// it's a netbird vnc-agent left over from a previous service instance, // terminates it. Verified by image-name match so we never kill an // unrelated process that happens to use the same port. -func reapOrphanOnPort(portStr string) { - port64, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - return - } - port := uint16(port64) +func reapOrphanOnPort(port uint16) { pid := tcpListenerPID(port) if pid == 0 || pid == uint32(windows.GetCurrentProcessId()) { return @@ -342,7 +326,7 @@ func injectEnvVar(envBlock uintptr, key, value string) []uint16 { return newBlock } -func spawnAgentInSession(sessionID uint32, port string, authToken string, jobHandle windows.Handle) (windows.Handle, error) { +func spawnAgentInSession(sessionID uint32, port uint16, authToken string, jobHandle windows.Handle) (windows.Handle, error) { token, err := getSystemTokenForSession(sessionID) if err != nil { return 0, fmt.Errorf("get SYSTEM token for session %d: %w", sessionID, err) @@ -372,7 +356,7 @@ func spawnAgentInSession(sessionID uint32, port string, authToken string, jobHan return 0, fmt.Errorf("get executable path: %w", err) } - cmdLine := fmt.Sprintf(`"%s" vnc-agent --port %s`, exePath, port) + cmdLine := fmt.Sprintf(`"%s" vnc-agent --port %d`, exePath, port) cmdLineW, err := windows.UTF16PtrFromString(cmdLine) if err != nil { return 0, fmt.Errorf("UTF16 cmdline: %w", err) @@ -445,7 +429,7 @@ func spawnAgentInSession(sessionID uint32, port string, authToken string, jobHan // Relog agent output in the service with a [vnc-agent] prefix. go relogAgentOutput(stderrRead) - log.Infof("spawned agent PID=%d in session %d on port %s", pi.ProcessId, sessionID, port) + log.Infof("spawned agent PID=%d in session %d on port %d", pi.ProcessId, sessionID, port) return pi.Process, nil } @@ -453,7 +437,7 @@ func spawnAgentInSession(sessionID uint32, port string, authToken string, jobHan // process is running in it. When the session changes (e.g., user switch, RDP // connect/disconnect), it kills the old agent and spawns a new one. type sessionManager struct { - port string + port uint16 mu sync.Mutex agentProc windows.Handle everSpawned bool @@ -470,7 +454,7 @@ type sessionManager struct { jobHandle windows.Handle } -func newSessionManager(port string) *sessionManager { +func newSessionManager(port uint16) *sessionManager { m := &sessionManager{port: port, sessionID: ^uint32(0), done: make(chan struct{})} if h, err := createKillOnCloseJob(); err != nil { log.Warnf("create job object for vnc-agent (orphan agents possible after crash): %v", err) @@ -528,16 +512,6 @@ func createKillOnCloseJob() (windows.Handle, error) { return job, nil } -// generateAuthToken creates a new random hex token for agent authentication. -func generateAuthToken() string { - b := make([]byte, agentTokenLen) - if _, err := crand.Read(b); err != nil { - log.Warnf("generate agent auth token: %v", err) - return "" - } - return hex.EncodeToString(b) -} - // AuthToken returns the current agent authentication token. func (m *sessionManager) AuthToken() string { m.mu.Lock() @@ -746,48 +720,6 @@ func relogAgentOutput(pipe windows.Handle) { } } -// proxyToAgent connects to the agent, sends the auth token, then proxies -// the VNC client connection bidirectionally. -func proxyToAgent(client net.Conn, port string, authToken string) { - defer client.Close() - - addr := "127.0.0.1:" + port - var agentConn net.Conn - var err error - for range 50 { - agentConn, err = net.DialTimeout("tcp", addr, time.Second) - if err == nil { - break - } - time.Sleep(200 * time.Millisecond) - } - if err != nil { - log.Warnf("proxy cannot reach agent at %s: %v", addr, err) - return - } - defer agentConn.Close() - - // Send the auth token so the agent can verify this connection - // comes from the trusted service process. - tokenBytes, _ := hex.DecodeString(authToken) - if _, err := agentConn.Write(tokenBytes); err != nil { - log.Warnf("send auth token to agent: %v", err) - return - } - - log.Debugf("proxy connected to agent, starting bidirectional copy") - - done := make(chan struct{}, 2) - cp := func(label string, dst, src net.Conn) { - n, err := io.Copy(dst, src) - log.Debugf("proxy %s: %d bytes, err=%v", label, n, err) - done <- struct{}{} - } - go cp("client→agent", agentConn, client) - go cp("agent→client", client, agentConn) - <-done -} - // logCleanupCall invokes a Windows syscall used solely as a cleanup primitive // (CloseClipboard, ReleaseDC, etc.) and logs failures at trace level. The // indirection lets us satisfy errcheck without scattering ignored returns at diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index efff86a9a66..ea7785cca02 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -240,7 +240,7 @@ func (s *Server) serviceAcceptLoop() { sm := newSessionManager(agentPort) go sm.run() - log.Infof("service mode, proxying connections to agent on 127.0.0.1:%s", agentPort) + log.Infof("service mode, proxying connections to agent on 127.0.0.1:%d", agentPort) for { conn, err := s.listener.Accept() From 7d61975f6cbdc2de53feb26c734a03493f43f39e Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 19 May 2026 17:24:10 +0200 Subject: [PATCH 054/151] Proxy macOS VNC connections from the LaunchDaemon to a per-user agent via launchctl asuser --- client/cmd/vnc_agent.go | 20 +- client/cmd/vnc_agent_darwin.go | 18 ++ client/cmd/vnc_agent_windows.go | 15 ++ client/internal/engine_vnc_darwin.go | 8 +- client/vnc/server/agent_darwin.go | 315 +++++++++++++++++++++++++++ client/vnc/server/agent_ipc.go | 62 ++++++ client/vnc/server/agent_windows.go | 58 +---- client/vnc/server/server.go | 10 +- client/vnc/server/server_darwin.go | 104 ++++++++- 9 files changed, 536 insertions(+), 74 deletions(-) create mode 100644 client/cmd/vnc_agent_darwin.go create mode 100644 client/cmd/vnc_agent_windows.go create mode 100644 client/vnc/server/agent_darwin.go diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go index c5b036b852c..15605784db9 100644 --- a/client/cmd/vnc_agent.go +++ b/client/cmd/vnc_agent.go @@ -1,4 +1,4 @@ -//go:build windows +//go:build windows || (darwin && !ios) package cmd @@ -20,30 +20,30 @@ func init() { rootCmd.AddCommand(vncAgentCmd) } -// vncAgentCmd runs a VNC server in the current user session, listening on -// localhost. It is spawned by the NetBird service (Session 0) via -// CreateProcessAsUser into the interactive console session. +// vncAgentCmd runs a VNC server inside the user's interactive session, +// listening on localhost. The NetBird service spawns it: on Windows via +// CreateProcessAsUser into the console session, on macOS via +// launchctl asuser into the Aqua session. var vncAgentCmd = &cobra.Command{ Use: "vnc-agent", Short: "Run VNC capture agent (internal, spawned by service)", Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { - // Agent's stderr is piped to the service which relogs it. - // Use JSON format with caller info for structured parsing. log.SetReportCaller(true) log.SetFormatter(&log.JSONFormatter{}) log.SetOutput(os.Stderr) - sessionID := vncserver.GetCurrentSessionID() - log.Infof("VNC agent starting on 127.0.0.1:%d (session %d)", vncAgentPort, sessionID) + log.Infof("VNC agent starting on 127.0.0.1:%d", vncAgentPort) token := os.Getenv("NB_VNC_AGENT_TOKEN") if token == "" { return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service") } - capturer := vncserver.NewDesktopCapturer() - injector := vncserver.NewWindowsInputInjector() + capturer, injector, err := newAgentResources() + if err != nil { + return err + } srv := vncserver.New(capturer, injector) srv.SetDisableAuth(true) srv.SetAgentToken(token) diff --git a/client/cmd/vnc_agent_darwin.go b/client/cmd/vnc_agent_darwin.go new file mode 100644 index 00000000000..6bf26460281 --- /dev/null +++ b/client/cmd/vnc_agent_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin && !ios + +package cmd + +import ( + "fmt" + + vncserver "github.com/netbirdio/netbird/client/vnc/server" +) + +func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) { + capturer := vncserver.NewMacPoller() + injector, err := vncserver.NewMacInputInjector() + if err != nil { + return nil, nil, fmt.Errorf("macOS input injector: %w", err) + } + return capturer, injector, nil +} diff --git a/client/cmd/vnc_agent_windows.go b/client/cmd/vnc_agent_windows.go new file mode 100644 index 00000000000..ea1247b55ca --- /dev/null +++ b/client/cmd/vnc_agent_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package cmd + +import ( + log "github.com/sirupsen/logrus" + + vncserver "github.com/netbirdio/netbird/client/vnc/server" +) + +func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) { + sessionID := vncserver.GetCurrentSessionID() + log.Infof("VNC agent running in Windows session %d", sessionID) + return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), nil +} diff --git a/client/internal/engine_vnc_darwin.go b/client/internal/engine_vnc_darwin.go index 0f182cdb065..309d14f5cca 100644 --- a/client/internal/engine_vnc_darwin.go +++ b/client/internal/engine_vnc_darwin.go @@ -3,6 +3,8 @@ package internal import ( + "os" + log "github.com/sirupsen/logrus" vncserver "github.com/netbirdio/netbird/client/vnc/server" @@ -23,6 +25,10 @@ func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) return capturer, injector, true } +// vncNeedsServiceMode reports whether the running process is a system +// LaunchDaemon (root, parented by launchd). Daemons sit in the global +// bootstrap namespace and cannot talk to WindowServer; we route capture +// through a per-user agent in that case. func vncNeedsServiceMode() bool { - return false + return os.Geteuid() == 0 && os.Getppid() == 1 } diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go new file mode 100644 index 00000000000..7b24cbc8e52 --- /dev/null +++ b/client/vnc/server/agent_darwin.go @@ -0,0 +1,315 @@ +//go:build darwin && !ios + +package server + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "strconv" + "sync" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +// darwinAgentManager spawns a per-user VNC agent on demand and keeps it +// alive across multiple client connections within the same console-user +// session. A new agent is spawned the first time a client connects, or +// whenever the console user changes underneath us. +// +// Lifecycle is lazy by design: a daemon that never receives a VNC +// connection never spawns anything. The trade-off versus an eager spawn +// (the Windows model) is that the first VNC client pays the launchctl +// asuser + listen-readiness wait, ~hundreds of milliseconds in practice. +// That cost only repeats on user switch. +type darwinAgentManager struct { + mu sync.Mutex + authToken string + port uint16 + uid uint32 + running bool +} + +func newDarwinAgentManager(ctx context.Context) *darwinAgentManager { + m := &darwinAgentManager{port: agentPort} + go m.watchConsoleUser(ctx) + return m +} + +// watchConsoleUser kills the cached agent whenever the console user +// changes (logout, fast user switch, login window). Without it the daemon +// keeps proxying to an agent whose TCC grant and WindowServer access +// belong to a user who is no longer at the screen, so the new user only +// ever sees the locked-screen wallpaper. Killing the agent breaks the +// loopback TCP that the daemon proxies into, the client disconnects, and +// the next reconnect runs ensure() against the new console uid. +func (m *darwinAgentManager) watchConsoleUser(ctx context.Context) { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + uid, err := consoleUserID() + m.mu.Lock() + if !m.running { + m.mu.Unlock() + continue + } + if err != nil || uid != m.uid { + prev := m.uid + m.killLocked() + m.mu.Unlock() + if err != nil { + log.Infof("console user gone (was uid=%d): %v; agent stopped", prev, err) + } else { + log.Infof("console user changed %d -> %d; agent stopped, will respawn on next connect", prev, uid) + } + continue + } + m.mu.Unlock() + } + } +} + +// ensure returns a token good for proxyToAgent. It spawns or respawns the +// per-user agent process as needed and waits until it is listening on the +// loopback port. Each ensure call is serialized so concurrent VNC clients +// share the same agent. +func (m *darwinAgentManager) ensure(ctx context.Context) (string, error) { + consoleUID, err := consoleUserID() + if err != nil { + return "", fmt.Errorf("no console user: %w", err) + } + m.mu.Lock() + defer m.mu.Unlock() + if m.running && m.uid == consoleUID && vncAgentRunning() { + return m.authToken, nil + } + m.killLocked() + + token := generateAuthToken() + if token == "" { + return "", fmt.Errorf("generate agent auth token") + } + if err := spawnAgentForUser(consoleUID, m.port, token); err != nil { + return "", err + } + if err := waitForAgent(ctx, m.port, 5*time.Second); err != nil { + killAllVNCAgents() + return "", fmt.Errorf("agent did not start listening: %w", err) + } + m.authToken = token + m.uid = consoleUID + m.running = true + log.Infof("spawned VNC agent for console uid=%d on port %d", consoleUID, m.port) + return token, nil +} + +// stop terminates the spawned agent, if any. Intended for daemon shutdown. +func (m *darwinAgentManager) stop() { + m.mu.Lock() + defer m.mu.Unlock() + m.killLocked() +} + +func (m *darwinAgentManager) killLocked() { + if !m.running { + return + } + killAllVNCAgents() + m.running = false + m.authToken = "" + m.uid = 0 +} + +// errNoConsoleUser is the sentinel callers use to recognise the +// "login window showing, no user signed in" state and surface it as a +// distinct condition to the VNC client. +var errNoConsoleUser = errors.New("no user logged into console") + +// consoleUserID returns the uid of the user currently sitting at the +// console (the one whose Aqua session is active). Returns +// errNoConsoleUser when nobody is logged in: at the login window +// /dev/console is owned by root. +func consoleUserID() (uint32, error) { + info, err := os.Stat("/dev/console") + if err != nil { + return 0, fmt.Errorf("stat /dev/console: %w", err) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, fmt.Errorf("/dev/console stat has unexpected type") + } + if st.Uid == 0 { + return 0, errNoConsoleUser + } + return st.Uid, nil +} + +// spawnAgentForUser uses launchctl asuser to start a netbird vnc-agent +// process inside the target user's launchd bootstrap namespace. That is +// the only spawn mode on macOS that gives the child access to the user's +// WindowServer. The agent's stderr is relogged into the daemon log so +// startup failures are not silently lost when the readiness check times +// out. +func spawnAgentForUser(uid uint32, port uint16, token string) error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve own executable: %w", err) + } + cmd := exec.Command( + "/bin/launchctl", "asuser", strconv.FormatUint(uint64(uid), 10), + exe, "vnc-agent", "--port", strconv.FormatUint(uint64(port), 10), + ) + cmd.Env = append(os.Environ(), agentTokenEnvVar+"="+token) + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("agent stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("launchctl asuser: %w", err) + } + go func() { + defer stderr.Close() + relogAgentStream(stderr) + }() + go func() { _ = cmd.Wait() }() + return nil +} + +// waitForAgent dials the loopback port until the agent answers. Used to +// gate proxy attempts until the spawned process has finished its Start. +func waitForAgent(ctx context.Context, port uint16, wait time.Duration) error { + addr := fmt.Sprintf("127.0.0.1:%d", port) + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + if ctx.Err() != nil { + return ctx.Err() + } + c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = c.Close() + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("timeout dialing %s", addr) +} + +// vncAgentRunning reports whether any vnc-agent process exists on the +// system. The daemon owns the only port-15900 listener model, so any +// match is "the" agent. +func vncAgentRunning() bool { + pids, err := vncAgentPIDs() + if err != nil { + log.Debugf("scan for vnc-agent: %v", err) + return false + } + return len(pids) > 0 +} + +// killAllVNCAgents sends SIGTERM to every process whose argv contains +// "vnc-agent", waits briefly for them to exit, and escalates to SIGKILL +// for any that remain. We enumerate kern.proc.all rather than +// kern.proc.uid because launchctl asuser preserves the caller's uid +// (root) on the spawned child, so a uid-scoped filter would never match. +func killAllVNCAgents() { + pids, err := vncAgentPIDs() + if err != nil { + log.Debugf("scan for vnc-agent: %v", err) + return + } + for _, pid := range pids { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + if len(pids) == 0 { + return + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + remaining, _ := vncAgentPIDs() + if len(remaining) == 0 { + return + } + time.Sleep(100 * time.Millisecond) + } + leftover, _ := vncAgentPIDs() + for _, pid := range leftover { + _ = syscall.Kill(pid, syscall.SIGKILL) + } +} + +// vncAgentPIDs returns the pids of every process whose argv contains +// "vnc-agent". Skips pid 0 and 1 defensively. +func vncAgentPIDs() ([]int, error) { + procs, err := unix.SysctlKinfoProcSlice("kern.proc.all") + if err != nil { + return nil, fmt.Errorf("sysctl kern.proc.all: %w", err) + } + var out []int + for i := range procs { + pid := int(procs[i].Proc.P_pid) + if pid <= 1 { + continue + } + argv, err := procArgv(pid) + if err != nil || !argvIsVNCAgent(argv) { + continue + } + out = append(out, pid) + } + return out, nil +} + +// procArgv reads the kernel's stored argv for pid via the kern.procargs2 +// sysctl. Format: 4-byte argc, then argv[0..argc) each NUL-terminated, +// then envp, then padding. We only need argv so we stop after argc. +func procArgv(pid int) ([]string, error) { + raw, err := unix.SysctlRaw("kern.procargs2", pid) + if err != nil { + return nil, err + } + if len(raw) < 4 { + return nil, fmt.Errorf("procargs2 truncated") + } + argc := int(raw[0]) | int(raw[1])<<8 | int(raw[2])<<16 | int(raw[3])<<24 + body := raw[4:] + // Skip the executable path (NUL-terminated) and any zero padding that + // follows before argv[0]. + end := bytes.IndexByte(body, 0) + if end < 0 { + return nil, fmt.Errorf("procargs2 path unterminated") + } + body = body[end+1:] + for len(body) > 0 && body[0] == 0 { + body = body[1:] + } + args := make([]string, 0, argc) + for i := 0; i < argc; i++ { + end := bytes.IndexByte(body, 0) + if end < 0 { + break + } + args = append(args, string(body[:end])) + body = body[end+1:] + } + return args, nil +} + +func argvIsVNCAgent(argv []string) bool { + for _, a := range argv { + if a == "vnc-agent" { + return true + } + } + return false +} diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index e0124ee3aad..8cf0ea8a6f9 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -3,8 +3,10 @@ package server import ( + "bufio" crand "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "io" "net" @@ -23,6 +25,12 @@ const ( // agentTokenLen is the size of the random per-spawn token in bytes. agentTokenLen = 32 + + // agentTokenEnvVar names the environment variable the daemon uses to + // hand the per-spawn token to the agent child. Out-of-band channels + // like this keep the secret out of the command line, where listings + // such as `ps` or Windows tasklist would expose it. + agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" // #nosec G101 -- env var name, not a credential ) // generateAuthToken returns a fresh hex-encoded random token for one @@ -73,6 +81,60 @@ func proxyToAgent(client net.Conn, port uint16, authToken string) { <-done } +// relogAgentStream reads log lines from the agent's stderr and re-emits +// them through the daemon's logrus, so the merged log keeps a single +// format. JSON lines (the agent's normal output) are parsed and dispatched +// by level; plain-text lines (cobra errors, panic traces) are forwarded +// verbatim so early-startup failures stay visible. +func relogAgentStream(r io.Reader) { + entry := log.WithField("component", "vnc-agent") + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + if line[0] != '{' { + entry.Warn(string(line)) + continue + } + var m map[string]any + if err := json.Unmarshal(line, &m); err != nil { + entry.Warn(string(line)) + continue + } + msg, _ := m["msg"].(string) + if msg == "" { + continue + } + fields := make(log.Fields) + for k, v := range m { + switch k { + case "msg", "level", "time", "func": + continue + case "caller": + fields["source"] = v + default: + fields[k] = v + } + } + e := entry.WithFields(fields) + switch m["level"] { + case "error": + e.Error(msg) + case "warning": + e.Warn(msg) + case "debug": + e.Debug(msg) + case "trace": + e.Trace(msg) + default: + e.Info(msg) + } + } +} + // dialAgentWithRetry retries the loopback connect for up to ~10 s so the // daemon does not race the agent's first listen. Returns the live conn or // the final error. diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 4bacbdd6f78..318076aaa37 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -3,9 +3,7 @@ package server import ( - "bufio" "encoding/binary" - "encoding/json" "errors" "fmt" "os" @@ -285,7 +283,6 @@ func getSystemTokenForSession(sessionID uint32) (windows.Token, error) { return dup, nil } -const agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" // #nosec G101 -- env var name, not a credential // injectEnvVar appends a KEY=VALUE entry to a Unicode environment block. // The block is a sequence of null-terminated UTF-16 strings, terminated by @@ -661,63 +658,12 @@ func (m *sessionManager) killAgent() { } // relogAgentOutput reads log lines from the agent's stderr pipe and -// relogs them with the service's formatter. Each line is tried as JSON -// first (the agent's normal log format); plain-text lines (e.g. cobra -// error output, panic stack traces) are forwarded verbatim so failures -// during early agent startup remain visible. +// relogs them with the service's formatter. func relogAgentOutput(pipe windows.Handle) { defer func() { _ = windows.CloseHandle(pipe) }() f := os.NewFile(uintptr(pipe), "vnc-agent-stderr") defer f.Close() - - entry := log.WithField("component", "vnc-agent") - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - continue - } - if line[0] != '{' { - entry.Warn(string(line)) - continue - } - var m map[string]any - if err := json.Unmarshal(line, &m); err != nil { - entry.Warn(string(line)) - continue - } - msg, _ := m["msg"].(string) - if msg == "" { - continue - } - - fields := make(log.Fields) - for k, v := range m { - switch k { - case "msg", "level", "time", "func": - continue - case "caller": - fields["source"] = v - default: - fields[k] = v - } - } - e := entry.WithFields(fields) - - switch m["level"] { - case "error": - e.Error(msg) - case "warning": - e.Warn(msg) - case "debug": - e.Debug(msg) - case "trace": - e.Trace(msg) - default: - e.Info(msg) - } - } + relogAgentStream(f) } // logCleanupCall invokes a Windows syscall used solely as a cleanup primitive diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 0768250744c..46fcc679f11 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -44,6 +44,7 @@ const ( RejectCodeCapturerError = "CAPTURER_ERROR" RejectCodeUnsupportedOS = "UNSUPPORTED" RejectCodeBadRequest = "BAD_REQUEST" + RejectCodeNoConsoleUser = "NO_CONSOLE_USER" ) // EnvVNCDisableDownscale disables any platform-specific framebuffer @@ -812,7 +813,14 @@ func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool { return false } if _, err := io.ReadFull(conn, buf); err != nil { - connLog.Warnf("agent auth: read token: %v", err) + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + // Connect-then-close probes (port liveness checks) hit this + // path on every dial; logging them would just flood the + // daemon log without surfacing a real failure. + connLog.Tracef("agent auth: read token: %v", err) + } else { + connLog.Warnf("agent auth: read token: %v", err) + } conn.Close() return false } diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 1217042228a..357b979b1fd 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -2,20 +2,112 @@ package server +import ( + "bytes" + "errors" + "io" + "net" + + log "github.com/sirupsen/logrus" +) + func (s *Server) platformInit() { // no-op on macOS } -// serviceAcceptLoop is not supported on macOS. -func (s *Server) serviceAcceptLoop() { - s.log.Warn("service mode not supported on macOS, falling back to direct mode") - s.acceptLoop() +func (s *Server) platformShutdown() { + // no-op on macOS } func (s *Server) platformSessionManager() virtualSessionManager { return nil } -func (s *Server) platformShutdown() { - // no-op on this platform +// serviceAcceptLoop runs in a LaunchDaemon and proxies each VNC +// connection to a per-user agent. The agent is spawned lazily on the +// first connection (and respawned after a console-user change) via +// launchctl asuser, which is the only mechanism that lands a child +// inside the user's Aqua session — where WindowServer and TCC grants +// for screen capture work. +func (s *Server) serviceAcceptLoop() { + mgr := newDarwinAgentManager(s.ctx) + defer mgr.stop() + + log.Infof("service mode, proxying connections to per-user agent on 127.0.0.1:%d", agentPort) + + for { + conn, err := s.listener.Accept() + if err != nil { + select { + case <-s.ctx.Done(): + return + default: + } + s.log.Debugf("accept VNC connection: %v", err) + continue + } + + enableTCPKeepAlive(conn, s.log) + conn = newMetricsConn(conn, s.sessionRecorder) + go s.handleServiceConnectionDarwin(conn, mgr) + } } + +func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentManager) { + connLog := s.log.WithField("remote", conn.RemoteAddr().String()) + + if !s.isAllowedSource(conn.RemoteAddr()) { + conn.Close() + return + } + + var headerBuf bytes.Buffer + tee := io.TeeReader(conn, &headerBuf) + teeConn := &darwinPrefixConn{Reader: tee, Conn: conn} + + header, err := readConnectionHeader(teeConn) + if err != nil { + connLog.Debugf("read connection header: %v", err) + conn.Close() + return + } + + if !s.disableAuth { + if s.jwtConfig == nil { + rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) + connLog.Warn("auth rejected: no identity provider configured") + return + } + if _, err := s.authenticateJWT(header); err != nil { + rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) + connLog.Warnf("auth rejected: %v", err) + return + } + } + + token, err := mgr.ensure(s.ctx) + if err != nil { + code := RejectCodeCapturerError + if errors.Is(err, errNoConsoleUser) { + code = RejectCodeNoConsoleUser + } + rejectConnection(conn, codeMessage(code, err.Error())) + connLog.Warnf("spawn per-user agent: %v", err) + return + } + + replayConn := &darwinPrefixConn{ + Reader: io.MultiReader(&headerBuf, conn), + Conn: conn, + } + proxyToAgent(replayConn, agentPort, token) +} + +// darwinPrefixConn replays the already-consumed connection-header bytes +// in front of the proxy stream, mirroring the Windows prefixConn shape. +type darwinPrefixConn struct { + io.Reader + net.Conn +} + +func (p *darwinPrefixConn) Read(b []byte) (int, error) { return p.Reader.Read(b) } From 5e200fa571547e1b4c956c4fc14f3ac7fea64252 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 10:20:30 +0200 Subject: [PATCH 055/151] Drop unreliable Sequoia preflight from macOS Screen Recording check --- client/vnc/server/capture_darwin.go | 53 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index 8f345ddc1a4..d113f8de30b 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -36,8 +36,7 @@ var ( cfDataGetLength func(uintptr) int64 cfDataGetBytePtr func(uintptr) uintptr cfRelease func(uintptr) - cgPreflightScreenCaptureAccess func() bool - cgRequestScreenCaptureAccess func() bool + cgRequestScreenCaptureAccess func() bool cgEventCreate func(uintptr) uintptr cgEventGetLocation func(uintptr) cgPoint darwinCaptureReady bool @@ -78,10 +77,10 @@ func initDarwinCapture() { purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr") purego.RegisterLibFunc(&cfRelease, cf, "CFRelease") - // Screen capture permission APIs (macOS 11+). Might not exist on older versions. - if sym, err := purego.Dlsym(cg, "CGPreflightScreenCaptureAccess"); err == nil { - purego.RegisterFunc(&cgPreflightScreenCaptureAccess, sym) - } + // CGRequestScreenCaptureAccess (macOS 11+) prompts on first call and + // is a cheap no-op once granted. The Preflight companion is unreliable + // on Sequoia (returns false even when access is granted), so we drive + // the permission flow from actual capture failures instead. if sym, err := purego.Dlsym(cg, "CGRequestScreenCaptureAccess"); err == nil { purego.RegisterFunc(&cgRequestScreenCaptureAccess, sym) } @@ -117,51 +116,51 @@ type CGCapturer struct { } // PrimeScreenCapturePermission triggers the macOS Screen Recording -// permission probe (and prompt, if not granted) without creating a full -// capturer. The platform wiring calls this at VNC-server enable time so -// the user sees the prompt the moment they turn the feature on, rather -// than on first-client-connect when the cause may not be obvious. +// permission prompt without creating a full capturer. The platform wiring +// calls this at VNC-server enable time so the user sees the prompt the +// moment they turn the feature on. CGRequestScreenCaptureAccess is a +// no-op when the grant already exists, so calling it on every enable is +// cheap and safe. func PrimeScreenCapturePermission() { initDarwinCapture() if !darwinCaptureReady { return } - if cgPreflightScreenCaptureAccess == nil || cgPreflightScreenCaptureAccess() { - return - } if cgRequestScreenCaptureAccess != nil { cgRequestScreenCaptureAccess() } - openPrivacyPane("Privacy_ScreenCapture") - log.Warn("Screen Recording permission not granted. Approve the prompt " + - "or grant in System Settings > Privacy & Security > Screen Recording.") } -// NewCGCapturer creates a screen capturer for the main display. -func NewCGCapturer() (*CGCapturer, error) { - initDarwinCapture() - if !darwinCaptureReady { - return nil, fmt.Errorf("CoreGraphics not available") - } +// notifyScreenRecordingMissing nudges the user once per agent process to +// approve Screen Recording. The capturer init retries on backoff when the +// grant is missing; without the sync.Once we would reopen System Settings +// every tick and flood the daemon log with the same warning. +var screenRecordingNotifyOnce sync.Once - // Request Screen Recording permission (shows system dialog on macOS 11+). - if cgPreflightScreenCaptureAccess != nil && !cgPreflightScreenCaptureAccess() { +func notifyScreenRecordingMissing() { + screenRecordingNotifyOnce.Do(func() { if cgRequestScreenCaptureAccess != nil { cgRequestScreenCaptureAccess() } openPrivacyPane("Privacy_ScreenCapture") log.Warn("Screen Recording permission not granted. " + "Opened System Settings > Privacy & Security > Screen Recording; enable netbird and restart.") + }) +} + +// NewCGCapturer creates a screen capturer for the main display. +func NewCGCapturer() (*CGCapturer, error) { + initDarwinCapture() + if !darwinCaptureReady { + return nil, fmt.Errorf("CoreGraphics not available") } displayID := cgMainDisplayID() c := &CGCapturer{displayID: displayID, downscale: 1, hashSeed: maphash.MakeSeed()} - // Probe actual pixel dimensions via a test capture. CGDisplayPixelsWide/High - // returns logical points on Retina, but CGDisplayCreateImage produces native - // pixels (often 2x), so probing the image is the only reliable source. img, err := c.Capture() if err != nil { + notifyScreenRecordingMissing() return nil, fmt.Errorf("probe capture: %w", err) } nativeW := img.Rect.Dx() From 02b9fe704b13f78a6bf81daa765f3909671ecae4 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 11:12:48 +0200 Subject: [PATCH 056/151] Use pixel-mode scroll on macOS for smoother wheel events --- client/vnc/server/input_darwin.go | 88 +++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 16 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 5b51b578ef1..ed46e50414d 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -7,6 +7,7 @@ import ( "os/exec" "strings" "sync" + "time" "unsafe" "github.com/ebitengine/purego" @@ -35,6 +36,18 @@ const ( kCGHIDEventTap int32 = 0 + // kCGMouseEventClickState (event field 1) tells macOS how many + // consecutive clicks of this button have happened. Without it, a + // double click looks like two independent single clicks and apps + // never see the dblclick (window-bar maximize, text word-select, ...). + kCGMouseEventClickState int32 = 1 + + // doubleClickWindow is the upper bound on the gap between two + // down events that still counts as a multi-click. macOS reads the + // user's setting from CGEventSourceGetDoubleClickInterval; 500ms is + // the default and works as a safe injection-side ceiling. + doubleClickWindow = 500 * time.Millisecond + // IOKit power management constants. kIOPMUserActiveLocal int32 = 0 kIOPMAssertionLevelOn uint32 = 255 @@ -48,8 +61,9 @@ var ( cgEventCreateKeyboardEvent func(uintptr, uint16, bool) uintptr // CGEventCreateMouseEvent takes CGPoint as two separate float64 args. // purego can't handle array/struct types but individual float64s work. - cgEventCreateMouseEvent func(uintptr, int32, float64, float64, int32) uintptr - cgEventPost func(int32, uintptr) + cgEventCreateMouseEvent func(uintptr, int32, float64, float64, int32) uintptr + cgEventPost func(int32, uintptr) + cgEventSetIntegerValueField func(uintptr, int32, int64) // CGEventCreateScrollWheelEvent is variadic, call via SyscallN. cgEventCreateScrollWheelEventAddr uintptr @@ -108,6 +122,7 @@ func initDarwinInput() { purego.RegisterLibFunc(&cgEventCreateKeyboardEvent, cg, "CGEventCreateKeyboardEvent") purego.RegisterLibFunc(&cgEventCreateMouseEvent, cg, "CGEventCreateMouseEvent") purego.RegisterLibFunc(&cgEventPost, cg, "CGEventPost") + purego.RegisterLibFunc(&cgEventSetIntegerValueField, cg, "CGEventSetIntegerValueField") sym, err := purego.Dlsym(cg, "CGEventCreateScrollWheelEvent") if err == nil { @@ -260,6 +275,12 @@ type MacInputInjector struct { lastButtons uint8 pbcopyPath string pbpastePath string + // clickCount[i] / clickAt[i] track the multi-click sequence for + // button i (0=left, 1=right, 2=middle). macOS apps reconstruct + // double/triple click semantics from the kCGMouseEventClickState + // field on each posted event, not from event timing. + clickCount [3]int64 + clickAt [3]time.Time } // NewMacInputInjector creates a macOS input injector. @@ -438,32 +459,49 @@ func (m *MacInputInjector) postMoveOrDrag(src uintptr, leftDown, rightDown bool, } } -// postButtonTransitions emits the up/down events for each button whose state -// changed against m.lastButtons. +// postButtonTransitions emits the up/down events for each button whose +// state changed against m.lastButtons, computing the click count so +// macOS recognises double / triple clicks. func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, x, y float64) { - emit := func(curBit, prevBit uint8, down, up int32, button int32) { + emit := func(curBit, prevBit uint8, down, up int32, button int32, idx int) { cur := buttonMask&curBit != 0 prev := m.lastButtons&prevBit != 0 if cur && !prev { - m.postMouse(src, down, x, y, button) + now := time.Now() + if !m.clickAt[idx].IsZero() && now.Sub(m.clickAt[idx]) <= doubleClickWindow { + m.clickCount[idx]++ + } else { + m.clickCount[idx] = 1 + } + m.clickAt[idx] = now + m.postMouseClick(src, down, x, y, button, m.clickCount[idx]) } else if !cur && prev { - m.postMouse(src, up, x, y, button) + count := m.clickCount[idx] + if count == 0 { + count = 1 + } + m.postMouseClick(src, up, x, y, button, count) } } - emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft) - emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight) - emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter) + emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft, 0) + emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight, 1) + emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter, 2) } func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint8) { if buttonMask&0x08 != 0 { - m.postScroll(src, 3) + m.postScroll(src, scrollLinesPerWheelTick) } if buttonMask&0x10 != 0 { - m.postScroll(src, -3) + m.postScroll(src, -scrollLinesPerWheelTick) } } +// scrollLinesPerWheelTick is what one wheel-button event (VNC button 4 / 5) +// translates to in macOS line units. Three matches the default per-notch +// scroll on macOS and what most VNC clients send for wheel events. +const scrollLinesPerWheelTick int32 = 3 + func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, button int32) { if cgEventCreateMouseEvent == nil { return @@ -476,15 +514,33 @@ func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, cfRelease(event) } +// postMouseClick stamps the click count on the event before posting it. +// Without this stamp macOS treats every press as a fresh single click. +func (m *MacInputInjector) postMouseClick(src uintptr, eventType int32, x, y float64, button int32, clickCount int64) { + if cgEventCreateMouseEvent == nil { + return + } + event := cgEventCreateMouseEvent(src, eventType, x, y, button) + if event == 0 { + return + } + if cgEventSetIntegerValueField != nil && clickCount > 1 { + cgEventSetIntegerValueField(event, kCGMouseEventClickState, clickCount) + } + cgEventPost(kCGHIDEventTap, event) + cfRelease(event) +} + func (m *MacInputInjector) postScroll(src uintptr, deltaY int32) { if cgEventCreateScrollWheelEventAddr == 0 { return } - // CGEventCreateScrollWheelEvent(source, units, wheelCount, wheel1delta) - // units=0 (pixel), wheelCount=1, wheel1delta=deltaY - // Variadic C function: pass args as uintptr via SyscallN. + // CGEventCreateScrollWheelEvent(source, units, wheelCount, wheel1delta). + // Line units (1) give the user-facing "one notch = a few lines" feel; + // pixel units (0) would need ~60-80 pixels per notch to match, and + // that depends on screen density. Variadic C function, pass via SyscallN. r1, _, _ := purego.SyscallN(cgEventCreateScrollWheelEventAddr, - src, 0, 1, uintptr(uint32(deltaY))) + src, 1, 1, uintptr(uint32(deltaY))) if r1 == 0 { return } From c28e41e82b3abac384ac2067ae6a499427b0c6a2 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 11:14:01 +0200 Subject: [PATCH 057/151] Track macOS click count and pixel-scale wheel scroll --- client/vnc/server/input_darwin.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index ed46e50414d..3fdbe6ee3c1 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -490,17 +490,19 @@ func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint8) { if buttonMask&0x08 != 0 { - m.postScroll(src, scrollLinesPerWheelTick) + m.postScroll(src, scrollPixelsPerWheelTick) } if buttonMask&0x10 != 0 { - m.postScroll(src, -scrollLinesPerWheelTick) + m.postScroll(src, -scrollPixelsPerWheelTick) } } -// scrollLinesPerWheelTick is what one wheel-button event (VNC button 4 / 5) -// translates to in macOS line units. Three matches the default per-notch -// scroll on macOS and what most VNC clients send for wheel events. -const scrollLinesPerWheelTick int32 = 3 +// scrollPixelsPerWheelTick is the pixel delta we post for one VNC wheel +// button event. noVNC accumulates the host wheel/trackpad deltaY and +// emits one press+release per ~10 px, so a real gesture arrives as many +// small events; 20 px per event keeps the resulting macOS scroll fluid +// without overshooting on a single notch. +const scrollPixelsPerWheelTick int32 = 20 func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, button int32) { if cgEventCreateMouseEvent == nil { @@ -536,11 +538,11 @@ func (m *MacInputInjector) postScroll(src uintptr, deltaY int32) { return } // CGEventCreateScrollWheelEvent(source, units, wheelCount, wheel1delta). - // Line units (1) give the user-facing "one notch = a few lines" feel; - // pixel units (0) would need ~60-80 pixels per notch to match, and - // that depends on screen density. Variadic C function, pass via SyscallN. + // Pixel units (0) feel smoother under noVNC's "one event per ~10 px of + // host wheel" emission than line units (1) where each event jumps a + // whole line. Variadic C function, pass via SyscallN. r1, _, _ := purego.SyscallN(cgEventCreateScrollWheelEventAddr, - src, 1, 1, uintptr(uint32(deltaY))) + src, 0, 1, uintptr(uint32(deltaY))) if r1 == 0 { return } From 354fd004c70de55b5e1022f4f505251b0a0912b0 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 11:30:14 +0200 Subject: [PATCH 058/151] Enable IdP JWKS refresh in VNC JWT validator --- client/vnc/server/input_darwin.go | 2 +- client/vnc/server/server.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 3fdbe6ee3c1..c88fdebad6e 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -502,7 +502,7 @@ func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint8) { // emits one press+release per ~10 px, so a real gesture arrives as many // small events; 20 px per event keeps the resulting macOS scroll fluid // without overshooting on a single notch. -const scrollPixelsPerWheelTick int32 = 20 +const scrollPixelsPerWheelTick int32 = 22 func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, button int32) { if cgEventCreateMouseEvent == nil { diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 46fcc679f11..29498c64acf 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -691,11 +691,13 @@ func (s *Server) ensureJWTValidator() error { return fmt.Errorf("no JWT config") } + // Enable IdP key refresh so JWKS rotations don't latch the validator + // off until daemon restart. s.jwtValidator = nbjwt.NewValidator( s.jwtConfig.Issuer, s.jwtConfig.Audiences, s.jwtConfig.KeysLocation, - false, + true, ) var opts []nbjwt.ClaimsExtractorOption From 896530fd8221d7c41f4642b6db93ad955eb62fc4 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 11:51:17 +0200 Subject: [PATCH 059/151] Add ExtendedMouseButtons for back/forward mouse buttons --- client/vnc/server/input_darwin.go | 21 +++++++----- client/vnc/server/input_uinput_unix.go | 10 ++++-- client/vnc/server/input_windows.go | 37 +++++++++++++++++++--- client/vnc/server/input_x11.go | 20 +++++++----- client/vnc/server/rfb.go | 1 + client/vnc/server/server.go | 5 ++- client/vnc/server/session.go | 44 ++++++++++++++++++++++++-- client/vnc/server/session_encode.go | 22 +++++++++++++ client/vnc/server/stubs.go | 2 +- 9 files changed, 133 insertions(+), 29 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index c88fdebad6e..eefce934cdb 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -272,15 +272,15 @@ func ensureEventSource() uintptr { // MacInputInjector injects keyboard and mouse events via Core Graphics. type MacInputInjector struct { - lastButtons uint8 + lastButtons uint16 pbcopyPath string pbpastePath string // clickCount[i] / clickAt[i] track the multi-click sequence for // button i (0=left, 1=right, 2=middle). macOS apps reconstruct // double/triple click semantics from the kCGMouseEventClickState // field on each posted event, not from event timing. - clickCount [3]int64 - clickAt [3]time.Time + clickCount [5]int64 + clickAt [5]time.Time } // NewMacInputInjector creates a macOS input injector. @@ -406,7 +406,7 @@ func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) { } // InjectPointer simulates mouse movement and button events. -func (m *MacInputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) { +func (m *MacInputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) { wakeDisplay() if serverW == 0 || serverH == 0 { return @@ -438,7 +438,7 @@ func scalePxToLogical(px, py, serverW, serverH int) (float64, float64) { float64(py) * float64(logicalH) / float64(serverH) } -func (m *MacInputInjector) dispatchPointer(src uintptr, buttonMask uint8, x, y float64) { +func (m *MacInputInjector) dispatchPointer(src uintptr, buttonMask uint16, x, y float64) { leftDown := buttonMask&0x01 != 0 rightDown := buttonMask&0x04 != 0 middleDown := buttonMask&0x02 != 0 @@ -462,8 +462,8 @@ func (m *MacInputInjector) postMoveOrDrag(src uintptr, leftDown, rightDown bool, // postButtonTransitions emits the up/down events for each button whose // state changed against m.lastButtons, computing the click count so // macOS recognises double / triple clicks. -func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, x, y float64) { - emit := func(curBit, prevBit uint8, down, up int32, button int32, idx int) { +func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint16, x, y float64) { + emit := func(curBit, prevBit uint16, down, up int32, button int32, idx int) { cur := buttonMask&curBit != 0 prev := m.lastButtons&prevBit != 0 if cur && !prev { @@ -486,9 +486,14 @@ func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft, 0) emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight, 1) emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter, 2) + // CG mouse-button numbers 3 (back) and 4 (forward) are emitted as + // "other" events; macOS apps that swallow Browser nav (Finder, web + // views) react to these directly. + emit(1<<7, 1<<7, kCGEventOtherMouseDown, kCGEventOtherMouseUp, 3, 3) + emit(1<<8, 1<<8, kCGEventOtherMouseDown, kCGEventOtherMouseUp, 4, 4) } -func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint8) { +func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint16) { if buttonMask&0x08 != 0 { m.postScroll(src, scrollPixelsPerWheelTick) } diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_unix.go index 4b70594f28e..098104678a5 100644 --- a/client/vnc/server/input_uinput_unix.go +++ b/client/vnc/server/input_uinput_unix.go @@ -42,6 +42,8 @@ const ( btnLeft = 0x110 btnRight = 0x111 btnMiddle = 0x112 + btnSide = 0x113 // mouse-back (X1) + btnExtra = 0x114 // mouse-forward (X2) ) // inputEvent matches struct input_event for x86_64 (timeval is 16 bytes). @@ -63,7 +65,7 @@ type UInputInjector struct { fd int closeOnce sync.Once keysymToKey map[uint32]uint16 - prevButtons uint8 + prevButtons uint16 screenW int screenH int } @@ -233,7 +235,7 @@ func (u *UInputInjector) emitKeyCode(code uint16, down bool) { // InjectPointer moves the absolute pointer and presses/releases buttons // based on the RFB button mask delta against the previous mask. -func (u *UInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) { +func (u *UInputInjector) InjectPointer(buttonMask uint16, x, y, serverW, serverH int) { u.mu.Lock() defer u.mu.Unlock() if serverW <= 1 || serverH <= 1 { @@ -245,13 +247,15 @@ func (u *UInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH _ = u.emit(evAbs, absY, absYVal) type btnMap struct { - bit uint8 + bit uint16 key uint16 } for _, b := range []btnMap{ {0x01, btnLeft}, {0x02, btnMiddle}, {0x04, btnRight}, + {1 << 7, btnSide}, + {1 << 8, btnExtra}, } { pressed := buttonMask&b.bit != 0 was := u.prevButtons&b.bit != 0 diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index aeeb35be639..b89ade86c96 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -30,9 +30,16 @@ const ( mouseeventfRightUp = 0x0010 mouseeventfMiddleDown = 0x0020 mouseeventfMiddleUp = 0x0040 + mouseeventfXDown = 0x0080 + mouseeventfXUp = 0x0100 mouseeventfWheel = 0x0800 mouseeventfAbsolute = 0x8000 + // X-button identifiers carried in the dwData field of MOUSEEVENTF_X* + // events. XBUTTON1 is mouse-back, XBUTTON2 is mouse-forward. + xButton1 = 0x0001 + xButton2 = 0x0002 + wheelDelta = 120 keyeventfExtendedKey = 0x0001 @@ -112,7 +119,7 @@ type inputCmd struct { keysym uint32 scancode uint32 down bool - buttonMask uint8 + buttonMask uint16 x, y int serverW int serverH int @@ -127,7 +134,7 @@ type WindowsInputInjector struct { ch chan inputCmd closed chan struct{} closeOnce sync.Once - prevButtonMask uint8 + prevButtonMask uint16 ctrlDown bool altDown bool } @@ -220,7 +227,7 @@ func (w *WindowsInputInjector) InjectKeyScancode(scancode uint32, keysym uint32, // thread. Pointer events coalesce: when the channel is full (slow desktop // switch, hung SendInput), drop the new sample so the read loop never // blocks. The next mouse event carries fresher position anyway. -func (w *WindowsInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) { +func (w *WindowsInputInjector) InjectPointer(buttonMask uint16, x, y, serverW, serverH int) { w.tryEnqueue(inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH}) } @@ -303,7 +310,7 @@ func signalSAS() { } } -func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, serverH int) { +func (w *WindowsInputInjector) doInjectPointer(buttonMask uint16, x, y, serverW, serverH int) { if serverW == 0 || serverH == 0 { return } @@ -317,7 +324,7 @@ func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, w.prevButtonMask = buttonMask type btnMap struct { - bit uint8 + bit uint16 down uint32 up uint32 } @@ -346,6 +353,26 @@ func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, if changed&0x10 != 0 && buttonMask&0x10 != 0 { sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, negWheelDelta) } + + // XBUTTON1/back at bit 7, XBUTTON2/forward at bit 8. SendInput + // MOUSEEVENTF_X{DOWN,UP} carries the X button number in dwData. + xbuttons := [...]struct { + bit uint16 + data uint32 + }{ + {1 << 7, xButton1}, + {1 << 8, xButton2}, + } + for _, b := range xbuttons { + if changed&b.bit == 0 { + continue + } + var flags uint32 = mouseeventfXUp + if buttonMask&b.bit != 0 { + flags = mouseeventfXDown + } + sendMouseInput(flags|mouseeventfAbsolute, absX, absY, b.data) + } } // keysym2VK converts an X11 keysym to a Windows virtual key code. diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index 60a325806e3..ca0c75631d5 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -22,7 +22,7 @@ type X11InputInjector struct { screen *xproto.ScreenInfo display string keysymMap map[uint32]byte - lastButtons uint8 + lastButtons uint16 clipboardTool string clipboardToolName string } @@ -110,7 +110,7 @@ func (x *X11InputInjector) fakeKeyEvent(keycode byte, down bool) { } // InjectPointer simulates mouse movement and button events. -func (x *X11InputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) { +func (x *X11InputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) { if serverW == 0 || serverH == 0 { return } @@ -128,15 +128,19 @@ func (x *X11InputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serv // bit3=scrollUp, bit4=scrollDown. X11 buttons: 1=left, 2=middle, 3=right, // 4=scrollUp, 5=scrollDown. type btnMap struct { - rfbBit uint8 + rfbBit uint16 x11Btn byte } + // X11 button numbers: 1=left, 2=middle, 3=right, 4/5=scroll up/down, + // 6/7=scroll left/right (skipped), 8=back, 9=forward. buttons := [...]btnMap{ - {0x01, 1}, // left - {0x02, 2}, // middle - {0x04, 3}, // right - {0x08, 4}, // scroll up - {0x10, 5}, // scroll down + {0x01, 1}, + {0x02, 2}, + {0x04, 3}, + {0x08, 4}, + {0x10, 5}, + {1 << 7, 8}, + {1 << 8, 9}, } for _, b := range buttons { diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 291d3529aec..97f7908b4bb 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -77,6 +77,7 @@ const ( pseudoEncQEMUExtendedKeyEvent = -258 pseudoEncDesktopName = -307 pseudoEncExtendedDesktopSize = -308 + pseudoEncExtendedMouseButtons = -316 // Quality/Compression level pseudo-encodings. The client picks one // value from each range to tune JPEG quality and zlib effort. 0 is diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 29498c64acf..29bc49a3ccf 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -105,7 +105,10 @@ type InputInjector interface { // for the given code; that's strictly no worse than the legacy path. InjectKeyScancode(scancode uint32, keysym uint32, down bool) // InjectPointer simulates mouse movement and button state. - InjectPointer(buttonMask uint8, x, y, serverW, serverH int) + // buttonMask is the RFB ExtendedMouseButtons mask: bits 0-6 follow + // the standard PointerEvent layout (left/middle/right/wheel), + // bit 7 is mouse-back (X1), bit 8 is mouse-forward (X2). + InjectPointer(buttonMask uint16, x, y, serverW, serverH int) // SetClipboard sets the system clipboard to the given text. SetClipboard(text string) // GetClipboard returns the current system clipboard text. diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 18eb4461da1..9bb063592f8 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -79,7 +79,18 @@ type session struct { clientSupportsQEMUKey bool clientSupportsExtClipboard bool clientSupportsCursor bool - extClipCapsSent bool + // clientSupportsExtMouseButtons is set when the client advertises the + // ExtendedMouseButtons pseudo-encoding (-316). Once the server emits + // the ack rect, the client switches its pointer events to the 6-byte + // extended format that carries back/forward buttons in a second mask + // byte. Without this gate the byte after the type field would still + // be a standard 7-bit mask and our parser must not look further. + clientSupportsExtMouseButtons bool + // extMouseAckSent is set once we've emitted the pseudo-rect ack that + // flips the client into extended-pointer mode. Sticky for the + // session because the client only needs to see it once. + extMouseAckSent bool + extClipCapsSent bool // lastCursorSerial is the serial of the cursor sprite last emitted. // The encoder re-queries the source each cycle and only emits when // the serial changes. @@ -359,6 +370,10 @@ func (s *session) handleSetEncodings() error { if sendExtClipCaps { s.extClipCapsSent = true } + sendExtMouseAck := s.clientSupportsExtMouseButtons && !s.extMouseAckSent + if sendExtMouseAck { + s.extMouseAckSent = true + } s.encMu.Unlock() if len(encs) > 0 { s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) @@ -368,6 +383,11 @@ func (s *session) handleSetEncodings() error { return fmt.Errorf("send ext clipboard caps: %w", err) } } + if sendExtMouseAck { + if err := s.sendExtMouseAck(); err != nil { + return fmt.Errorf("send ext mouse ack: %w", err) + } + } return nil } @@ -387,6 +407,7 @@ func (s *session) resetEncodingCaps() { s.clientSupportsQEMUKey = false s.clientSupportsExtClipboard = false s.clientSupportsCursor = false + s.clientSupportsExtMouseButtons = false s.cursorSourceFailed = false s.clientJPEGQuality = -1 s.clientZlibLevel = -1 @@ -427,6 +448,9 @@ func (s *session) applyEncoding(enc int32) string { } s.clientSupportsCursor = true return "cursor" + case pseudoEncExtendedMouseButtons: + s.clientSupportsExtMouseButtons = true + return "ext-mouse-buttons" case encTight: s.useTight = true return "tight" @@ -541,14 +565,28 @@ func (s *session) handlePointerEvent() error { if _, err := io.ReadFull(s.conn, data[:]); err != nil { return fmt.Errorf("read PointerEvent: %w", err) } - buttonMask := data[0] + mask := uint16(data[0]) x := int(binary.BigEndian.Uint16(data[1:3])) y := int(binary.BigEndian.Uint16(data[3:5])) + + s.encMu.RLock() + extended := s.clientSupportsExtMouseButtons && s.extMouseAckSent + s.encMu.RUnlock() + if extended && mask&0x80 != 0 { + var hi [1]byte + if _, err := io.ReadFull(s.conn, hi[:]); err != nil { + return fmt.Errorf("read ExtendedPointerEvent tail: %w", err) + } + // Strip the marker bit; bits 0..6 are the low part of the mask, + // hi byte holds bits 7..14 (back at bit 7, forward at bit 8). + mask = (mask & 0x7f) | uint16(hi[0])<<7 + } + s.pointerMu.Lock() s.lastPointerX = x s.lastPointerY = y s.pointerMu.Unlock() - s.injector.InjectPointer(buttonMask, x, y, s.serverW, s.serverH) + s.injector.InjectPointer(mask, x, y, s.serverW, s.serverH) return nil } diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 8068bb1d7dc..c2346a2f418 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -269,6 +269,28 @@ func (s *session) sendDesktopSize(w, h int) error { return err } +// sendExtMouseAck emits the pseudo-rect that flips the client into +// ExtendedMouseButtons mode, where mouse-back and mouse-forward are +// carried in a second mask byte. The rect has zero geometry and no +// body; the encoding number alone is the signal. +func (s *session) sendExtMouseAck() error { + header := make([]byte, 4) + header[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(header[2:4], 1) + + rect := make([]byte, 12) + enc := int32(pseudoEncExtendedMouseButtons) + binary.BigEndian.PutUint32(rect[8:12], uint32(enc)) + + s.writeMu.Lock() + defer s.writeMu.Unlock() + if _, err := s.conn.Write(header); err != nil { + return err + } + _, err := s.conn.Write(rect) + return err +} + // refreshCopyRectIndex does a full hash sweep of the just-swapped prevFrame. // Used after full-frame sends, where we don't have a per-tile dirty list to // drive an incremental update. diff --git a/client/vnc/server/stubs.go b/client/vnc/server/stubs.go index 0417252e0d3..954607ada4a 100644 --- a/client/vnc/server/stubs.go +++ b/client/vnc/server/stubs.go @@ -35,7 +35,7 @@ func (s *StubInputInjector) InjectKeyScancode(_ uint32, _ uint32, _ bool) { } // InjectPointer is a no-op on unsupported platforms. -func (s *StubInputInjector) InjectPointer(_ uint8, _, _, _, _ int) { +func (s *StubInputInjector) InjectPointer(_ uint16, _, _, _, _ int) { // no-op } From 517bea0daffe6852e4957b0afb4734c7920b7df9 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 13:13:53 +0200 Subject: [PATCH 060/151] Collapse X11 DISPLAY/XAUTHORITY auto-detect logs into one line --- client/vnc/server/agent_windows.go | 3 +- client/vnc/server/capture_darwin.go | 35 ++++++++--------- client/vnc/server/capture_fb_linux.go | 1 - client/vnc/server/capture_fb_unix.go | 1 - client/vnc/server/capture_x11.go | 25 +++++++----- client/vnc/server/copyrect.go | 2 +- client/vnc/server/copyrect_test.go | 2 +- client/vnc/server/cursor_windows.go | 8 ++-- client/vnc/server/input_darwin.go | 24 ++++++------ client/vnc/server/input_windows.go | 1 - client/vnc/server/input_x11.go | 15 ++++---- client/vnc/server/metrics_conn.go | 4 +- client/vnc/server/rfb.go | 34 ++++++++-------- client/vnc/server/server.go | 45 ++++++++++++---------- client/vnc/server/server_darwin.go | 2 +- client/vnc/server/session.go | 25 ++++++------ client/vnc/server/session_clipboard.go | 7 ++-- client/vnc/server/session_remote_cursor.go | 11 +++--- client/vnc/server/swizzle.go | 1 - client/vnc/server/tight_test.go | 5 +-- client/vnc/server/virtual_x11.go | 6 +-- 21 files changed, 131 insertions(+), 126 deletions(-) diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 318076aaa37..3dded65fa69 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -48,7 +48,7 @@ var ( procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory") procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW") - iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") + iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable") ) @@ -283,7 +283,6 @@ func getSystemTokenForSession(sessionID uint32) (windows.Token, error) { return dup, nil } - // injectEnvVar appends a KEY=VALUE entry to a Unicode environment block. // The block is a sequence of null-terminated UTF-16 strings, terminated by // an extra null. Returns the new []uint16 backing slice; the caller must diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index d113f8de30b..6b975ed87bf 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -22,24 +22,24 @@ import ( var darwinCaptureOnce sync.Once var ( - cgMainDisplayID func() uint32 - cgDisplayPixelsWide func(uint32) uintptr - cgDisplayPixelsHigh func(uint32) uintptr - cgDisplayCreateImage func(uint32) uintptr - cgImageGetWidth func(uintptr) uintptr - cgImageGetHeight func(uintptr) uintptr - cgImageGetBytesPerRow func(uintptr) uintptr - cgImageGetBitsPerPixel func(uintptr) uintptr - cgImageGetDataProvider func(uintptr) uintptr - cgDataProviderCopyData func(uintptr) uintptr - cgImageRelease func(uintptr) - cfDataGetLength func(uintptr) int64 - cfDataGetBytePtr func(uintptr) uintptr - cfRelease func(uintptr) + cgMainDisplayID func() uint32 + cgDisplayPixelsWide func(uint32) uintptr + cgDisplayPixelsHigh func(uint32) uintptr + cgDisplayCreateImage func(uint32) uintptr + cgImageGetWidth func(uintptr) uintptr + cgImageGetHeight func(uintptr) uintptr + cgImageGetBytesPerRow func(uintptr) uintptr + cgImageGetBitsPerPixel func(uintptr) uintptr + cgImageGetDataProvider func(uintptr) uintptr + cgDataProviderCopyData func(uintptr) uintptr + cgImageRelease func(uintptr) + cfDataGetLength func(uintptr) int64 + cfDataGetBytePtr func(uintptr) uintptr + cfRelease func(uintptr) cgRequestScreenCaptureAccess func() bool - cgEventCreate func(uintptr) uintptr - cgEventGetLocation func(uintptr) cgPoint - darwinCaptureReady bool + cgEventCreate func(uintptr) uintptr + cgEventGetLocation func(uintptr) cgPoint + darwinCaptureReady bool ) // cgPoint mirrors CoreGraphics CGPoint: two doubles, 16 bytes, returned @@ -98,7 +98,6 @@ func initDarwinCapture() { }) } - // CGCapturer captures the macOS main display using Core Graphics. type CGCapturer struct { displayID uint32 diff --git a/client/vnc/server/capture_fb_linux.go b/client/vnc/server/capture_fb_linux.go index 9e33a22a245..f0f13e14256 100644 --- a/client/vnc/server/capture_fb_linux.go +++ b/client/vnc/server/capture_fb_linux.go @@ -227,4 +227,3 @@ func swizzleFB32(dst []byte, dstStride int, src []byte, srcStride, w, h int, shi } } } - diff --git a/client/vnc/server/capture_fb_unix.go b/client/vnc/server/capture_fb_unix.go index 01981371f92..0c2a0dac0cc 100644 --- a/client/vnc/server/capture_fb_unix.go +++ b/client/vnc/server/capture_fb_unix.go @@ -109,7 +109,6 @@ func (p *FBPoller) ensureCapturerLocked() error { return nil } - var _ ScreenCapturer = (*FBPoller)(nil) var _ captureIntoer = (*FBPoller)(nil) diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index 93ee84ae1e4..1d373c6912d 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -18,6 +18,12 @@ import ( "github.com/jezek/xgb/xproto" ) +// x11SocketDir is the well-known directory where X servers create their +// abstract UNIX-domain sockets, named "X". Used both for +// auto-detecting an existing display and for placing/probing sockets of +// virtual sessions we spawn. +const x11SocketDir = "/tmp/.X11-unix" + // X11Capturer captures the screen from an X11 display using the MIT-SHM extension. type X11Capturer struct { mu sync.Mutex @@ -26,7 +32,7 @@ type X11Capturer struct { w, h int shmID int shmAddr []byte - shmSeg uint32 // shm.Seg + shmSeg uint32 useSHM bool // bufs double-buffers output images so the X11Poller's capture loop can // overwrite one while the session is still encoding the other. Before @@ -83,7 +89,7 @@ func detectX11FromProc() bool { // detectX11FromSockets checks /tmp/.X11-unix/ for X sockets and uses ps // to find the auth file. Works on FreeBSD and other systems without /proc. func detectX11FromSockets() bool { - entries, err := os.ReadDir("/tmp/.X11-unix") + entries, err := os.ReadDir(x11SocketDir) if err != nil { return false } @@ -96,12 +102,12 @@ func detectX11FromSockets() bool { } display := ":" + name[1:] os.Setenv("DISPLAY", display) - log.Infof("auto-detected DISPLAY=%s (from socket)", display) - - // Try to find -auth from ps output. - if auth := findXorgAuthFromPS(); auth != "" { + auth := findXorgAuthFromPS() + if auth != "" { os.Setenv("XAUTHORITY", auth) - log.Infof("auto-detected XAUTHORITY=%s (from ps)", auth) + log.Infof("auto-detected DISPLAY=%s (from socket) XAUTHORITY=%s (from ps)", display, auth) + } else { + log.Infof("auto-detected DISPLAY=%s (from socket)", display) } return true } @@ -150,11 +156,12 @@ func parseXorgArgs(args []string) (display, auth string) { func setDisplayEnv(display, auth string) { os.Setenv("DISPLAY", display) - log.Infof("auto-detected DISPLAY=%s", display) if auth != "" { os.Setenv("XAUTHORITY", auth) - log.Infof("auto-detected XAUTHORITY=%s", auth) + log.Infof("auto-detected DISPLAY=%s XAUTHORITY=%s", display, auth) + return } + log.Infof("auto-detected DISPLAY=%s", display) } func splitCmdline(data []byte) []string { diff --git a/client/vnc/server/copyrect.go b/client/vnc/server/copyrect.go index 97d2756ae48..2e0fb56fd97 100644 --- a/client/vnc/server/copyrect.go +++ b/client/vnc/server/copyrect.go @@ -37,7 +37,7 @@ type copyRectDetector struct { cols, rows int // tileHash[ty*cols + tx] is the current hash of the tile at (tx, ty) // in the previous frame. Lookup uses this to detect stale prevTiles - // entries — incremental updates may leave hash→pos entries pointing + // entries: incremental updates may leave hash→pos entries pointing // at a tile whose content has since changed. tileHash []uint64 // prevTiles maps a tile hash to a (x, y) origin in the previous frame. diff --git a/client/vnc/server/copyrect_test.go b/client/vnc/server/copyrect_test.go index 51f0d23b5e4..0295e6c2605 100644 --- a/client/vnc/server/copyrect_test.go +++ b/client/vnc/server/copyrect_test.go @@ -43,7 +43,7 @@ func TestCopyRectDetector_DetectsVerticalScroll(t *testing.T) { fillTile(prev, tx*ts, ty*ts, ts, byte(tx*40), byte(ty*60), 0x80) } } - // cur: simulate a single-tile-row scroll upward — every tile copied from + // cur: simulate a single-tile-row scroll upward, every tile copied from // the row below in prev, top row is new content. for ty := 0; ty < 2; ty++ { for tx := 0; tx < 4; tx++ { diff --git a/client/vnc/server/cursor_windows.go b/client/vnc/server/cursor_windows.go index 85e34d833a1..9e4c9855462 100644 --- a/client/vnc/server/cursor_windows.go +++ b/client/vnc/server/cursor_windows.go @@ -42,10 +42,10 @@ type winPoint struct { } type winCursorInfo struct { - Size uint32 - Flags uint32 - Cursor windows.Handle - PtPos winPoint + Size uint32 + Flags uint32 + Cursor windows.Handle + PtPos winPoint } type winIconInfo struct { diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index eefce934cdb..2e8542aa84d 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -104,7 +104,9 @@ var ( userActivityID uint32 preventSleepID uint32 preventSleepHeld bool - preventSleepRef int // refcount across concurrent injectors/sessions + // preventSleepRef tracks the refcount of held assertions across + // concurrent injectors and sessions. + preventSleepRef int darwinInputReady bool darwinEventSource uintptr @@ -503,10 +505,10 @@ func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint16) { } // scrollPixelsPerWheelTick is the pixel delta we post for one VNC wheel -// button event. noVNC accumulates the host wheel/trackpad deltaY and -// emits one press+release per ~10 px, so a real gesture arrives as many -// small events; 20 px per event keeps the resulting macOS scroll fluid -// without overshooting on a single notch. +// button event. Browser-based RFB clients typically emit one press+release +// per ~10 px of host wheel/trackpad motion, so a real gesture arrives as +// many small events; ~20 px per event keeps the resulting macOS scroll +// fluid without overshooting on a single notch. const scrollPixelsPerWheelTick int32 = 22 func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, button int32) { @@ -543,8 +545,8 @@ func (m *MacInputInjector) postScroll(src uintptr, deltaY int32) { return } // CGEventCreateScrollWheelEvent(source, units, wheelCount, wheel1delta). - // Pixel units (0) feel smoother under noVNC's "one event per ~10 px of - // host wheel" emission than line units (1) where each event jumps a + // Pixel units (0) feel smoother given the small per-event deltas typical + // of RFB wheel events than line units (1) where each event jumps a // whole line. Variadic C function, pass via SyscallN. r1, _, _ := purego.SyscallN(cgEventCreateScrollWheelEventAddr, src, 0, 1, uintptr(uint32(deltaY))) @@ -568,10 +570,10 @@ func (m *MacInputInjector) SetClipboard(text string) { } // TypeText synthesizes the given text as keystrokes via Core Graphics. -// Used by the dashboard's Paste button so the host clipboard reaches -// the focused remote app even when the app doesn't honor pbpaste-style -// clipboard sync (e.g. login screens, locked-down apps). ASCII printable -// runes only; others are skipped. +// Lets a client push host clipboard content to the focused remote app +// even when the app doesn't honor pbpaste-style clipboard sync (e.g. +// login screens, locked-down apps). ASCII printable runes only; others +// are skipped. func (m *MacInputInjector) TypeText(text string) { wakeDisplay() src := ensureEventSource() diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index b89ade86c96..385317bd7ac 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -48,7 +48,6 @@ const ( keyeventfScanCode = 0x0008 ) - // maxTypedClipboardChars caps the number of characters we will synthesize as // keystrokes when falling back on the Winlogon desktop. Passwords are short; // a huge clipboard getting typed into the login screen would be surprising. diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index ca0c75631d5..e7fbc9a2644 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -219,16 +219,15 @@ func (x *X11InputInjector) SetClipboard(text string) { } } -// TypeText synthesizes the given text as keystrokes via XTest. We can -// no longer just stuff the host clipboard with xclip and expect Ctrl+V -// to do the rest, because the Paste button is also used at places where -// the focused application isn't a clipboard-aware one (e.g. a TTY login -// in an X11 session, an SDDM/GDM password field that ignores XSelection, -// or a kiosk app). Typing keystrokes covers all of those. +// TypeText synthesizes the given text as keystrokes via XTest. Used in +// places where the focused application isn't clipboard-aware (e.g. a TTY +// login in an X11 session, an SDDM/GDM password field that ignores +// XSelection, or a kiosk app), so stuffing the X clipboard and relying on +// Ctrl+V would not reach the input. // // Limitation: only ASCII printable characters are typed. Non-ASCII runes // are skipped: a paste workflow for them needs Wayland-aware text input -// or layout introspection that we don't have. +// or layout introspection that this path does not implement. func (x *X11InputInjector) TypeText(text string) { const maxChars = 4096 count := 0 @@ -289,7 +288,7 @@ func (x *X11InputInjector) GetClipboard() string { out, err := cmd.Output() if err != nil { // Exit status 1 just means there is no STRING selection set yet, - // which is the steady state on a fresh Xvfb session — logging it + // which is the steady state on a fresh Xvfb session, logging it // every clipboard poll (2s) floods the trace stream. return "" } diff --git a/client/vnc/server/metrics_conn.go b/client/vnc/server/metrics_conn.go index 2baec750ace..60f31101e14 100644 --- a/client/vnc/server/metrics_conn.go +++ b/client/vnc/server/metrics_conn.go @@ -26,8 +26,8 @@ type SessionTick struct { } // sessionTickInterval is how often metricsConn emits a SessionTick. One -// second matches noVNC's request cadence so each tick covers roughly one -// FBU round-trip during steady-state activity. +// second covers roughly one FBU round-trip at typical client request +// cadences during steady-state activity. const sessionTickInterval = time.Second // metricsConn wraps a net.Conn and tracks per-session byte / write / FBU diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 97f7908b4bb..af6ab3114e5 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -42,17 +42,17 @@ const ( // clientNetbirdTypeText is a NetBird-specific message that asks the // server to synthesize the given text as keystrokes regardless of the - // active desktop. Used by the dashboard's Paste button to push host - // clipboard content into a Windows secure desktop (Winlogon, UAC), - // where the OS clipboard is isolated. Format mirrors clientCutText: - // 1-byte message type + 3-byte padding + 4-byte length + text bytes. - // The opcode is in the vendor-specific range (>=128). + // active desktop. Lets a client push host clipboard content into a + // Windows secure desktop (Winlogon, UAC), where the OS clipboard is + // isolated. Format mirrors clientCutText: 1-byte message type + 3-byte + // padding + 4-byte length + text bytes. The opcode is in the + // vendor-specific range (>=128). clientNetbirdTypeText = 250 // clientNetbirdShowRemoteCursor toggles "show remote cursor" mode. // When enabled the encoder composites the server cursor sprite into // the captured framebuffer and suppresses the Cursor pseudo-encoding - // so the dashboard sees a single pointer at the remote position. + // so the client sees a single pointer at the remote position. // Wire format: 1-byte msgType + 1-byte enable flag + 6 padding bytes // reserved for future arguments (so the message is fixed-size). clientNetbirdShowRemoteCursor = 251 @@ -71,13 +71,13 @@ const ( // Pseudo-encodings carried over wire as rects with a negative // encoding value. The client advertises supported optional protocol // extensions by listing these in SetEncodings. - pseudoEncCursor = -239 - pseudoEncDesktopSize = -223 - pseudoEncLastRect = -224 - pseudoEncQEMUExtendedKeyEvent = -258 - pseudoEncDesktopName = -307 - pseudoEncExtendedDesktopSize = -308 - pseudoEncExtendedMouseButtons = -316 + pseudoEncCursor = -239 + pseudoEncDesktopSize = -223 + pseudoEncLastRect = -224 + pseudoEncQEMUExtendedKeyEvent = -258 + pseudoEncDesktopName = -307 + pseudoEncExtendedDesktopSize = -308 + pseudoEncExtendedMouseButtons = -316 // Quality/Compression level pseudo-encodings. The client picks one // value from each range to tune JPEG quality and zlib effort. 0 is @@ -405,10 +405,10 @@ func coalesceRects(in [][4]int) [][4]int { // algorithm can be split across small methods without long parameter lists // and to keep each method's cognitive complexity below Sonar's threshold. type rectCoalescer struct { - out [][4]int - prevRowStart, prevRowEnd int - curRowStart int - curY int + out [][4]int + prevRowStart, prevRowEnd int + curRowStart int + curY int } func newRectCoalescer(capacity int) *rectCoalescer { diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 29bc49a3ccf..60b7b51a9c1 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -32,8 +32,8 @@ const ( ) // RFB security-failure reason codes sent to the client. These prefixes are -// stable so dashboard integrations can branch on them without parsing -// free text. Format: "CODE: human message". +// stable so clients can branch on them without parsing free text. +// Format: "CODE: human message". const ( RejectCodeJWTMissing = "AUTH_JWT_MISSING" RejectCodeJWTExpired = "AUTH_JWT_EXPIRED" @@ -114,10 +114,9 @@ type InputInjector interface { // GetClipboard returns the current system clipboard text. GetClipboard() string // TypeText synthesizes the given text as keystrokes on the active - // desktop. Used by the dashboard's Paste button to push host clipboard - // content into a secure desktop (Winlogon/UAC) where the clipboard is - // isolated. On platforms or sessions without keystroke synthesis it - // may be a no-op. + // desktop. Used to push host clipboard content into a secure desktop + // (Winlogon/UAC) where the clipboard is isolated. On platforms or + // sessions without keystroke synthesis it may be a no-op. TypeText(text string) } @@ -132,10 +131,11 @@ type JWTConfig struct { // connectionHeader is sent by the client before the RFB handshake to specify // the VNC session mode and authenticate. type connectionHeader struct { - mode byte - username string - jwt string - sessionID uint32 // Windows session ID (0 = console/auto) + mode byte + username string + jwt string + // sessionID is the Windows session ID; 0 selects the console session. + sessionID uint32 // width and height request the virtual display geometry for session mode. // Zero means use the default. width uint16 @@ -159,9 +159,11 @@ type Server struct { injector InputInjector serviceMode bool disableAuth bool - localAddr netip.Addr // NetBird WireGuard IP this server is bound to - network netip.Prefix // NetBird overlay network - log *log.Entry + // localAddr is the NetBird WireGuard IP this server is bound to. + localAddr netip.Addr + // network is the NetBird overlay network. + network netip.Prefix + log *log.Entry mu sync.Mutex listener net.Listener @@ -173,7 +175,8 @@ type Server struct { jwtExtractor *nbjwt.ClaimsExtractor authorizer *sshauth.Authorizer netstackNet *netstack.Net - agentToken []byte // raw token bytes for agent-mode auth + // agentToken holds the raw token bytes for agent-mode auth. + agentToken []byte sessionsMu sync.Mutex sessionSeq uint64 @@ -212,14 +215,14 @@ type virtualSessionManager interface { } // New creates a VNC server with the given screen capturer and input injector. -// Authentication is handled by the dashboard JWT exchange after the RFB -// handshake; the protocol-level VNC password scheme is not supported. +// Authentication uses a JWT supplied by the client in the connection +// header; the protocol-level VNC password scheme is not supported. func New(capturer ScreenCapturer, injector InputInjector) *Server { return &Server{ - capturer: capturer, - injector: injector, - authorizer: sshauth.NewAuthorizer(), - log: log.WithField("component", "vnc-server"), + capturer: capturer, + injector: injector, + authorizer: sshauth.NewAuthorizer(), + log: log.WithField("component", "vnc-server"), sessions: make(map[uint64]ActiveSessionInfo), sessionConns: make(map[uint64]net.Conn), } @@ -576,7 +579,7 @@ func (s *Server) handleConnection(conn net.Conn) { serverH: capturer.Height(), log: connLog, // Virtual sessions run on Xvfb which has no usable cursor source, - // so we skip the Cursor pseudo-encoding and let the dashboard's + // so we skip the Cursor pseudo-encoding and let the client's // local fallback show instead. disableCursor: header.mode == ModeSession, } diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 357b979b1fd..161ca7dc66e 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -27,7 +27,7 @@ func (s *Server) platformSessionManager() virtualSessionManager { // connection to a per-user agent. The agent is spawned lazily on the // first connection (and respawned after a console-user change) via // launchctl asuser, which is the only mechanism that lands a child -// inside the user's Aqua session — where WindowServer and TCC grants +// inside the user's Aqua session, where WindowServer and TCC grants // for screen capture work. func (s *Server) serviceAcceptLoop() { mgr := newDarwinAgentManager(s.ctx) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 9bb063592f8..11a177f7a28 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -106,7 +106,7 @@ type session struct { // showRemoteCursor switches the encoder to compositing the server // cursor sprite into the captured framebuffer at the remote position // instead of emitting the Cursor pseudo-encoding. Toggled by the - // dashboard via clientNetbirdShowRemoteCursor. + // client via clientNetbirdShowRemoteCursor. showRemoteCursor bool // cursorWarnOnce throttles the diagnostic emitted when remote-cursor // compositing falls back to a no-op (capturer cannot supply a sprite @@ -140,9 +140,9 @@ type session struct { // pointerMu guards the cached last cursor position used by // releaseStickyInput so the disconnect-time button-release event // targets the cursor's current spot instead of warping to (0, 0). - pointerMu sync.Mutex - lastPointerX int - lastPointerY int + pointerMu sync.Mutex + lastPointerX int + lastPointerY int } type fbRequest struct { @@ -226,8 +226,9 @@ func (s *session) handshake() error { } // sendSecurityTypes advertises only secNone. Authentication and access -// control are layered on top by the dashboard JWT exchange after the RFB -// handshake completes, not by the protocol-level password scheme. +// control happen in the NetBird connection header (JWT, mode, username) +// that precedes the RFB handshake, not via the protocol-level password +// scheme. func (s *session) sendSecurityTypes() error { _, err := s.conn.Write([]byte{1, secNone}) return err @@ -502,9 +503,9 @@ func (s *session) handleFBUpdateRequest() error { } // SendDesktopName pushes a DesktopName pseudo-encoded update to the -// client if it advertised support. Used by the server to keep the -// dashboard title in sync with the active session (e.g. username -// changes after login on a virtual session). +// client if it advertised support. Lets the client keep its window title +// in sync with the active session (e.g. username changes after login on +// a virtual session). func (s *session) SendDesktopName(name string) error { s.encMu.RLock() supported := s.clientSupportsDesktopName @@ -601,9 +602,9 @@ var stickyModifierKeysyms = [...]uint32{ 0xffe9, 0xffea, // Alt_L, Alt_R 0xffe7, 0xffe8, // Meta_L, Meta_R 0xffeb, 0xffec, // Super_L, Super_R - 0xff7e, // Mode_switch - 0xfe03, // ISO_Level3_Shift (AltGr) - 0xffe5, // Caps_Lock (release if user dropped mid-press) + 0xff7e, // Mode_switch + 0xfe03, // ISO_Level3_Shift (AltGr) + 0xffe5, // Caps_Lock (release if user dropped mid-press) } // releaseStickyInput synthesizes key-up for modifier keysyms and a diff --git a/client/vnc/server/session_clipboard.go b/client/vnc/server/session_clipboard.go index c4554970faa..c31b0b8e42c 100644 --- a/client/vnc/server/session_clipboard.go +++ b/client/vnc/server/session_clipboard.go @@ -220,9 +220,10 @@ func (s *session) writeExtClipMessage(payload []byte) error { return err } -// handleTypeText handles the NetBird-specific PasteAndType message used by -// the dashboard's Paste button. Wire format mirrors CutText: 3-byte -// padding + 4-byte length + text bytes. +// handleTypeText handles the NetBird-specific PasteAndType message that +// pushes host clipboard content as synthesized keystrokes, used to reach +// secure desktops where the clipboard is isolated. Wire format mirrors +// CutText: 3-byte padding + 4-byte length + text bytes. func (s *session) handleTypeText() error { var header [7]byte if _, err := io.ReadFull(s.conn, header[:]); err != nil { diff --git a/client/vnc/server/session_remote_cursor.go b/client/vnc/server/session_remote_cursor.go index 2ed77320c10..b5bcfc62e78 100644 --- a/client/vnc/server/session_remote_cursor.go +++ b/client/vnc/server/session_remote_cursor.go @@ -8,9 +8,9 @@ import ( "io" ) -// handleShowRemoteCursor handles the NetBird-specific RFB message used by -// the dashboard to toggle "show remote cursor" mode. Wire format: 1-byte -// enable flag (0/1) plus 6 padding bytes reserved for future arguments. +// handleShowRemoteCursor handles the NetBird-specific RFB message that +// toggles "show remote cursor" mode. Wire format: 1-byte enable flag +// (0/1) plus 6 padding bytes reserved for future arguments. func (s *session) handleShowRemoteCursor() error { var data [7]byte if _, err := io.ReadFull(s.conn, data[:]); err != nil { @@ -25,9 +25,8 @@ func (s *session) handleShowRemoteCursor() error { } // maybeCompositeCursor blends the current server cursor into img when the -// dashboard has enabled "show remote cursor" mode. Returns silently in -// every error path: a failed compositing must not stop the regular encode -// flow. +// client has enabled "show remote cursor" mode. Returns silently in every +// error path: a failed compositing must not stop the regular encode flow. func (s *session) maybeCompositeCursor(img *image.RGBA) { s.encMu.RLock() enabled := s.showRemoteCursor diff --git a/client/vnc/server/swizzle.go b/client/vnc/server/swizzle.go index e94a933b6dc..264a2f5a162 100644 --- a/client/vnc/server/swizzle.go +++ b/client/vnc/server/swizzle.go @@ -28,4 +28,3 @@ func swizzleBGRAtoRGBA(dst, src []byte) { dp[i] = 0xFF000000 | (p & 0x0000FF00) | ((p & 0x00FF0000) >> 16) | ((p & 0x000000FF) << 16) } } - diff --git a/client/vnc/server/tight_test.go b/client/vnc/server/tight_test.go index 808c1b01a0d..926dc7cb737 100644 --- a/client/vnc/server/tight_test.go +++ b/client/vnc/server/tight_test.go @@ -101,12 +101,11 @@ func TestEncodeTightJPEG(t *testing.T) { func TestSampledColorCount(t *testing.T) { uniform := makeUniformImage(64, 64, 0x10, 0x20, 0x30) - if c := sampledColorCountInto(map[uint32]struct{}{},uniform, 0, 0, 64, 64, 32); c != 1 { + if c := sampledColorCountInto(map[uint32]struct{}{}, uniform, 0, 0, 64, 64, 32); c != 1 { t.Fatalf("uniform should be 1 colour, got %d", c) } rnd := makeBenchImage(128, 128, 1) - if c := sampledColorCountInto(map[uint32]struct{}{},rnd, 0, 0, 128, 128, 16); c <= 16 { + if c := sampledColorCountInto(map[uint32]struct{}{}, rnd, 0, 0, 128, 128, 16); c <= 16 { t.Fatalf("random image should exceed colour cap, got %d", c) } } - diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go index bc2b426c22f..2d3f1cbc726 100644 --- a/client/vnc/server/virtual_x11.go +++ b/client/vnc/server/virtual_x11.go @@ -118,7 +118,7 @@ func (vs *VirtualSession) start() error { return err } - socketPath := fmt.Sprintf("/tmp/.X11-unix/X%s", vs.display[1:]) + socketPath := fmt.Sprintf("%s/X%s", x11SocketDir, vs.display[1:]) if err := waitForPath(socketPath, 5*time.Second); err != nil { vs.stopXvfb() return fmt.Errorf("wait for X11 socket %s: %w", socketPath, err) @@ -196,7 +196,7 @@ func (vs *VirtualSession) isAlive() bool { return false } // Verify the X socket still exists on disk. - socketPath := fmt.Sprintf("/tmp/.X11-unix/X%s", display[1:]) + socketPath := fmt.Sprintf("%s/X%s", x11SocketDir, display[1:]) if _, err := os.Stat(socketPath); err != nil { return false } @@ -590,7 +590,7 @@ func bestSessionCandidate(candidates []sessionCandidate) sessionCandidate { func findFreeDisplay() (string, error) { for n := 50; n < 200; n++ { lockFile := fmt.Sprintf("/tmp/.X%d-lock", n) - socketFile := fmt.Sprintf("/tmp/.X11-unix/X%d", n) + socketFile := fmt.Sprintf("%s/X%d", x11SocketDir, n) if _, err := os.Stat(lockFile); err == nil { continue } From 17359cdc1e41c404f98f1a2debcbfb0461d334c2 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 16:34:29 +0200 Subject: [PATCH 061/151] Fix VNC lint, 386 atomic alignment, and Sonar code smells --- client/vnc/server/agent_ipc.go | 2 +- client/vnc/server/cursor_windows.go | 57 ++++++++++++--------- client/vnc/server/metrics_conn.go | 70 +++++++++++++------------- client/vnc/server/session.go | 78 ++++++++++++++++++----------- 4 files changed, 116 insertions(+), 91 deletions(-) diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index 8cf0ea8a6f9..1842653bfb8 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -1,4 +1,4 @@ -//go:build !js && !ios && !android +//go:build darwin || windows package server diff --git a/client/vnc/server/cursor_windows.go b/client/vnc/server/cursor_windows.go index 9e4c9855462..491af28651b 100644 --- a/client/vnc/server/cursor_windows.go +++ b/client/vnc/server/cursor_windows.go @@ -259,43 +259,50 @@ func decodeColorCursor(hbmColor, hbmMask windows.Handle) (*image.RGBA, error) { if hbmMask != 0 { mask, _ = dibCopy(hbmMask, w, h) } + hasAlpha := colorHasAlpha(color) img := image.NewRGBA(image.Rect(0, 0, int(w), int(h))) - hasAlpha := false - for i := 0; i < len(color); i += 4 { - if color[i+3] != 0 { - hasAlpha = true - break - } - } for y := int32(0); y < h; y++ { for x := int32(0); x < w; x++ { si := (y*w + x) * 4 - di := (y*w + x) * 4 b := color[si] g := color[si+1] r := color[si+2] - a := color[si+3] - if !hasAlpha { - a = 255 - if mask != nil { - // AND mask: 1 = transparent, 0 = opaque. The DIB - // representation we requested is 32bpp so each "bit" - // is a 4-byte entry; we use the first byte as the - // effective AND value. - if mask[si] != 0 { - a = 0 - } - } - } - img.Pix[di+0] = r - img.Pix[di+1] = g - img.Pix[di+2] = b - img.Pix[di+3] = a + a := pixelAlpha(color[si+3], si, mask, hasAlpha) + img.Pix[si+0] = r + img.Pix[si+1] = g + img.Pix[si+2] = b + img.Pix[si+3] = a } } return img, nil } +// colorHasAlpha reports whether any pixel of a 32bpp BGRA buffer has a +// non-zero alpha. Cursors authored without alpha leave the channel at 0 +// and rely on hbmMask for transparency. +func colorHasAlpha(color []byte) bool { + for i := 0; i < len(color); i += 4 { + if color[i+3] != 0 { + return true + } + } + return false +} + +// pixelAlpha returns the effective alpha for a colour-cursor pixel. When +// the source bitmap already has alpha we trust it; otherwise the AND mask +// decides (1 = transparent, 0 = opaque). The 32bpp DIB stores each AND +// bit as a 4-byte entry; the first byte carries the effective value. +func pixelAlpha(colorA byte, si int32, mask []byte, hasAlpha bool) byte { + if hasAlpha { + return colorA + } + if mask != nil && mask[si] != 0 { + return 0 + } + return 255 +} + // decodeMonoCursor handles legacy 1bpp cursors where hbmMask is twice as // tall as the visible sprite: rows [0..h) are the AND mask and rows [h..2h) // are the XOR mask. We render the visible half into RGBA, treating diff --git a/client/vnc/server/metrics_conn.go b/client/vnc/server/metrics_conn.go index 60f31101e14..75749629ad0 100644 --- a/client/vnc/server/metrics_conn.go +++ b/client/vnc/server/metrics_conn.go @@ -41,15 +41,15 @@ type metricsConn struct { recorder func(SessionTick) - bytesOut uint64 - writes uint64 - writeNanos uint64 - largestPkt uint64 - fbus uint64 - fbuBytes uint64 - fbuRects uint64 - maxFBUBytes uint64 - maxFBURects uint64 + bytesOut atomic.Uint64 + writes atomic.Uint64 + writeNanos atomic.Uint64 + largestPkt atomic.Uint64 + fbus atomic.Uint64 + fbuBytes atomic.Uint64 + fbuRects atomic.Uint64 + maxFBUBytes atomic.Uint64 + maxFBURects atomic.Uint64 tickMu sync.Mutex tickStart time.Time @@ -104,10 +104,10 @@ func (m *metricsConn) flushTick(final bool) { m.tickMu.Lock() defer m.tickMu.Unlock() - b := atomic.LoadUint64(&m.bytesOut) - w := atomic.LoadUint64(&m.writes) - f := atomic.LoadUint64(&m.fbus) - ns := atomic.LoadUint64(&m.writeNanos) + b := m.bytesOut.Load() + w := m.writes.Load() + f := m.fbus.Load() + ns := m.writeNanos.Load() db := b - m.tickPrevB dw := w - m.tickPrevW @@ -115,9 +115,9 @@ func (m *metricsConn) flushTick(final bool) { dns := ns - m.tickPrevNS m.tickPrevB, m.tickPrevW, m.tickPrevF, m.tickPrevNS = b, w, f, ns - maxFBU := atomic.SwapUint64(&m.maxFBUBytes, 0) - maxRects := atomic.SwapUint64(&m.maxFBURects, 0) - maxPkt := atomic.SwapUint64(&m.largestPkt, 0) + maxFBU := m.maxFBUBytes.Swap(0) + maxRects := m.maxFBURects.Swap(0) + maxPkt := m.largestPkt.Swap(0) period := time.Since(m.tickStart) m.tickStart = time.Now() @@ -144,7 +144,7 @@ func (m *metricsConn) flushTick(final bool) { // throttle JPEG quality or skip frames in response. func (m *metricsConn) BusyFraction() float64 { now := time.Now() - ns := atomic.LoadUint64(&m.writeNanos) + ns := m.writeNanos.Load() m.busyMu.Lock() defer m.busyMu.Unlock() @@ -179,30 +179,30 @@ func isFBUHeader(p []byte) bool { func (m *metricsConn) Write(p []byte) (int, error) { if isFBUHeader(p) { - if b := atomic.SwapUint64(&m.fbuBytes, 0); b > 0 { - if b > atomic.LoadUint64(&m.maxFBUBytes) { - atomic.StoreUint64(&m.maxFBUBytes, b) + if b := m.fbuBytes.Swap(0); b > 0 { + if b > m.maxFBUBytes.Load() { + m.maxFBUBytes.Store(b) } } - if r := atomic.SwapUint64(&m.fbuRects, 0); r > 0 { - if r > atomic.LoadUint64(&m.maxFBURects) { - atomic.StoreUint64(&m.maxFBURects, r) + if r := m.fbuRects.Swap(0); r > 0 { + if r > m.maxFBURects.Load() { + m.maxFBURects.Store(r) } } - atomic.AddUint64(&m.fbus, 1) + m.fbus.Add(1) } t0 := time.Now() n, err := m.Conn.Write(p) - atomic.AddUint64(&m.writeNanos, uint64(time.Since(t0).Nanoseconds())) - atomic.AddUint64(&m.bytesOut, uint64(n)) - atomic.AddUint64(&m.writes, 1) + m.writeNanos.Add(uint64(time.Since(t0).Nanoseconds())) + m.bytesOut.Add(uint64(n)) + m.writes.Add(1) if !isFBUHeader(p) { - atomic.AddUint64(&m.fbuBytes, uint64(n)) - atomic.AddUint64(&m.fbuRects, 1) + m.fbuBytes.Add(uint64(n)) + m.fbuRects.Add(1) } - if uint64(n) > atomic.LoadUint64(&m.largestPkt) { - atomic.StoreUint64(&m.largestPkt, uint64(n)) + if uint64(n) > m.largestPkt.Load() { + m.largestPkt.Store(uint64(n)) } return n, err } @@ -213,11 +213,11 @@ func (m *metricsConn) Close() error { if m.recorder == nil { return } - if b := atomic.SwapUint64(&m.fbuBytes, 0); b > atomic.LoadUint64(&m.maxFBUBytes) { - atomic.StoreUint64(&m.maxFBUBytes, b) + if b := m.fbuBytes.Swap(0); b > m.maxFBUBytes.Load() { + m.maxFBUBytes.Store(b) } - if r := atomic.SwapUint64(&m.fbuRects, 0); r > atomic.LoadUint64(&m.maxFBURects) { - atomic.StoreUint64(&m.maxFBURects, r) + if r := m.fbuRects.Swap(0); r > m.maxFBURects.Load() { + m.maxFBURects.Store(r) } m.flushTick(true) }) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 11a177f7a28..bd355a780d1 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -342,54 +342,72 @@ func (s *session) handleSetEncodings() error { return err } - var encs []string + encs, sendExtClipCaps, sendExtMouseAck := s.applyEncodings(buf, int(numEnc)) + if len(encs) > 0 { + s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) + } + if sendExtClipCaps { + if err := s.writeExtClipMessage(buildExtClipCaps()); err != nil { + return fmt.Errorf("send ext clipboard caps: %w", err) + } + } + if sendExtMouseAck { + if err := s.sendExtMouseAck(); err != nil { + return fmt.Errorf("send ext mouse ack: %w", err) + } + } + return nil +} + +// applyEncodings parses the SetEncodings body, updates capability flags, +// rebuilds the tight state if quality/level changed, and reports which +// one-shot acknowledgements still need to be sent. +func (s *session) applyEncodings(buf []byte, numEnc int) (names []string, sendExtClipCaps, sendExtMouseAck bool) { s.encMu.Lock() + defer s.encMu.Unlock() // Per RFC 6143 §7.5.3 each SetEncodings replaces the previous list, so // reset all flags before re-applying. extClipCapsSent stays sticky so // we don't re-emit Caps every refresh. s.resetEncodingCaps() - for i := range int(numEnc) { + for i := range numEnc { enc := int32(binary.BigEndian.Uint32(buf[i*4 : i*4+4])) if name := s.applyEncoding(enc); name != "" { - encs = append(encs, name) - } - } - if s.useTight && (s.tight == nil || - s.tight.qualityLevel != s.clientJPEGQuality || - s.tight.compressLevel != s.clientZlibLevel) { - // When we replace an in-use tightState the client's stream-0 - // inflater carries dictionary state from the old deflater. Carry - // the pending-reset flag so the next Basic rect tells the client - // to reset its inflater before decoding. - replacing := s.tight != nil - s.tight = newTightStateWithLevels(s.clientJPEGQuality, s.clientZlibLevel) - if replacing { - s.tight.pendingZlibReset = true + names = append(names, name) } } - sendExtClipCaps := s.clientSupportsExtClipboard && !s.extClipCapsSent + s.refreshTightStateLocked() + sendExtClipCaps = s.clientSupportsExtClipboard && !s.extClipCapsSent if sendExtClipCaps { s.extClipCapsSent = true } - sendExtMouseAck := s.clientSupportsExtMouseButtons && !s.extMouseAckSent + sendExtMouseAck = s.clientSupportsExtMouseButtons && !s.extMouseAckSent if sendExtMouseAck { s.extMouseAckSent = true } - s.encMu.Unlock() - if len(encs) > 0 { - s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) + return names, sendExtClipCaps, sendExtMouseAck +} + +// refreshTightStateLocked reallocates s.tight when the requested quality +// or compression level no longer matches the cached state. Caller holds +// s.encMu. +func (s *session) refreshTightStateLocked() { + if !s.useTight { + return } - if sendExtClipCaps { - if err := s.writeExtClipMessage(buildExtClipCaps()); err != nil { - return fmt.Errorf("send ext clipboard caps: %w", err) - } + if s.tight != nil && + s.tight.qualityLevel == s.clientJPEGQuality && + s.tight.compressLevel == s.clientZlibLevel { + return } - if sendExtMouseAck { - if err := s.sendExtMouseAck(); err != nil { - return fmt.Errorf("send ext mouse ack: %w", err) - } + // When we replace an in-use tightState the client's stream-0 + // inflater carries dictionary state from the old deflater. Carry + // the pending-reset flag so the next Basic rect tells the client + // to reset its inflater before decoding. + replacing := s.tight != nil + s.tight = newTightStateWithLevels(s.clientJPEGQuality, s.clientZlibLevel) + if replacing { + s.tight.pendingZlibReset = true } - return nil } // resetEncodingCaps zeroes the encoding capability flags so the next pass From 640a267556b7cd4ebaa6997f279646e5a14d2a7a Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 17:16:45 +0200 Subject: [PATCH 062/151] Address CodeRabbit feedback on VNC server --- client/vnc/server/agent_darwin.go | 32 +++++++++---- client/vnc/server/agent_ipc.go | 16 ++++--- client/vnc/server/agent_windows.go | 13 ++++-- client/vnc/server/capture_x11.go | 34 +++++++++----- client/vnc/server/cursor_windows.go | 18 ++++++-- client/vnc/server/extclipboard.go | 4 +- client/vnc/server/input_uinput_unix.go | 2 +- client/vnc/server/input_windows.go | 45 +++++++++++++++--- client/vnc/server/input_x11.go | 2 +- client/vnc/server/server.go | 54 ++++++++++++++++------ client/vnc/server/server_darwin.go | 6 ++- client/vnc/server/server_windows.go | 6 ++- client/vnc/server/session_encode.go | 6 ++- client/vnc/server/session_remote_cursor.go | 18 ++++---- 14 files changed, 187 insertions(+), 69 deletions(-) diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index 7b24cbc8e52..c4aa9423483 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -10,6 +10,7 @@ import ( "net" "os" "os/exec" + "path/filepath" "strconv" "sync" "syscall" @@ -95,10 +96,13 @@ func (m *darwinAgentManager) ensure(ctx context.Context) (string, error) { return m.authToken, nil } m.killLocked() + // Reap any stray external vnc-agent so the new token is the only one + // the freshly spawned agent will accept on the loopback port. + killAllVNCAgents() - token := generateAuthToken() - if token == "" { - return "", fmt.Errorf("generate agent auth token") + token, err := generateAuthToken() + if err != nil { + return "", fmt.Errorf("generate agent auth token: %w", err) } if err := spawnAgentForUser(consoleUID, m.port, token); err != nil { return "", err @@ -248,13 +252,16 @@ func killAllVNCAgents() { } } -// vncAgentPIDs returns the pids of every process whose argv contains -// "vnc-agent". Skips pid 0 and 1 defensively. +// vncAgentPIDs returns the pids of vnc-agent subprocesses spawned from +// this binary. Matches on (argv[0] basename == our own basename) AND +// argv contains the "vnc-agent" subcommand. Skips pid 0 and 1 defensively. func vncAgentPIDs() ([]int, error) { procs, err := unix.SysctlKinfoProcSlice("kern.proc.all") if err != nil { return nil, fmt.Errorf("sysctl kern.proc.all: %w", err) } + ownExe, _ := os.Executable() + ownBase := filepath.Base(ownExe) var out []int for i := range procs { pid := int(procs[i].Proc.P_pid) @@ -262,7 +269,7 @@ func vncAgentPIDs() ([]int, error) { continue } argv, err := procArgv(pid) - if err != nil || !argvIsVNCAgent(argv) { + if err != nil || !argvIsVNCAgent(argv, ownBase) { continue } out = append(out, pid) @@ -305,8 +312,17 @@ func procArgv(pid int) ([]string, error) { return args, nil } -func argvIsVNCAgent(argv []string) bool { - for _, a := range argv { +// argvIsVNCAgent reports whether argv belongs to a vnc-agent subprocess +// spawned from our binary. Requires argv[0]'s basename to match ownBase +// and the "vnc-agent" subcommand to appear among the positional args. +func argvIsVNCAgent(argv []string, ownBase string) bool { + if len(argv) < 2 || ownBase == "" { + return false + } + if filepath.Base(argv[0]) != ownBase { + return false + } + for _, a := range argv[1:] { if a == "vnc-agent" { return true } diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index 1842653bfb8..4253007720a 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -36,15 +36,13 @@ const ( // generateAuthToken returns a fresh hex-encoded random token for one // daemon→agent session. The daemon hands this to the spawned agent // out-of-band (env var on Windows) and verifies it on every connection -// the agent accepts. Returns the empty string on a randomness failure; -// callers should treat that as an error. -func generateAuthToken() string { +// the agent accepts. +func generateAuthToken() (string, error) { b := make([]byte, agentTokenLen) if _, err := crand.Read(b); err != nil { - log.Warnf("generate agent auth token: %v", err) - return "" + return "", fmt.Errorf("read random: %w", err) } - return hex.EncodeToString(b) + return hex.EncodeToString(b), nil } // proxyToAgent dials the per-session agent on TCP loopback, writes the @@ -63,7 +61,11 @@ func proxyToAgent(client net.Conn, port uint16, authToken string) { } defer agentConn.Close() - tokenBytes, _ := hex.DecodeString(authToken) + tokenBytes, err := hex.DecodeString(authToken) + if err != nil || len(tokenBytes) != agentTokenLen { + log.Warnf("invalid auth token (len=%d): %v", len(tokenBytes), err) + return + } if _, err := agentConn.Write(tokenBytes); err != nil { log.Warnf("send auth token to agent: %v", err) return diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 3dded65fa69..dfae07272ef 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -626,7 +626,12 @@ func (m *sessionManager) maybeSpawnAgent(sid uint32) bool { if !m.everSpawned { reapOrphanOnPort(m.port) } - m.authToken = generateAuthToken() + token, err := generateAuthToken() + if err != nil { + log.Warnf("generate agent auth token: %v", err) + return true + } + m.authToken = token h, err := spawnAgentInSession(sid, m.port, m.authToken, m.jobHandle) if err != nil { m.authToken = "" @@ -657,11 +662,11 @@ func (m *sessionManager) killAgent() { } // relogAgentOutput reads log lines from the agent's stderr pipe and -// relogs them with the service's formatter. +// relogs them with the service's formatter. The *os.File owns the +// underlying handle, so closing it suffices. func relogAgentOutput(pipe windows.Handle) { - defer func() { _ = windows.CloseHandle(pipe) }() f := os.NewFile(uintptr(pipe), "vnc-agent-stderr") - defer f.Close() + defer func() { _ = f.Close() }() relogAgentStream(f) } diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index 1d373c6912d..fb28d9a125f 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -7,6 +7,7 @@ import ( "image" "os" "os/exec" + "strconv" "strings" "sync" "sync/atomic" @@ -94,24 +95,35 @@ func detectX11FromSockets() bool { return false } - // Find the lowest display number. + // Pick the lowest numeric display rather than the lexically first + // entry, so X10 doesn't win over X2. + minDisplay := -1 for _, e := range entries { name := e.Name() if len(name) < 2 || name[0] != 'X' { continue } - display := ":" + name[1:] - os.Setenv("DISPLAY", display) - auth := findXorgAuthFromPS() - if auth != "" { - os.Setenv("XAUTHORITY", auth) - log.Infof("auto-detected DISPLAY=%s (from socket) XAUTHORITY=%s (from ps)", display, auth) - } else { - log.Infof("auto-detected DISPLAY=%s (from socket)", display) + n, err := strconv.Atoi(name[1:]) + if err != nil { + continue + } + if minDisplay < 0 || n < minDisplay { + minDisplay = n } - return true } - return false + if minDisplay < 0 { + return false + } + display := ":" + strconv.Itoa(minDisplay) + os.Setenv("DISPLAY", display) + auth := findXorgAuthFromPS() + if auth != "" { + os.Setenv("XAUTHORITY", auth) + log.Infof("auto-detected DISPLAY=%s (from socket) XAUTHORITY=%s (from ps)", display, auth) + } else { + log.Infof("auto-detected DISPLAY=%s (from socket)", display) + } + return true } // findXorgAuthFromPS runs ps to find Xorg and extract its -auth argument. diff --git a/client/vnc/server/cursor_windows.go b/client/vnc/server/cursor_windows.go index 491af28651b..b9a92834f26 100644 --- a/client/vnc/server/cursor_windows.go +++ b/client/vnc/server/cursor_windows.go @@ -173,10 +173,10 @@ func decodeCursor(hCur windows.Handle) (*image.RGBA, int, int, error) { } defer func() { if info.HbmMask != 0 { - procDeleteObject.Call(uintptr(info.HbmMask)) + _, _, _ = procDeleteObject.Call(uintptr(info.HbmMask)) } if info.HbmColor != 0 { - procDeleteObject.Call(uintptr(info.HbmColor)) + _, _, _ = procDeleteObject.Call(uintptr(info.HbmColor)) } }() hotX, hotY := int(info.XHotspot), int(info.YHotspot) @@ -212,12 +212,12 @@ func dibCopy(hbm windows.Handle, w, h int32) ([]byte, error) { if hdcScreen == 0 { return nil, fmt.Errorf("GetDC: failed") } - defer procReleaseDC.Call(0, hdcScreen) + defer func() { _, _, _ = procReleaseDC.Call(0, hdcScreen) }() hdcMem, _, _ := procCreateCompatDC.Call(hdcScreen) if hdcMem == 0 { return nil, fmt.Errorf("CreateCompatibleDC: failed") } - defer procDeleteDC.Call(hdcMem) + defer func() { _, _, _ = procDeleteDC.Call(hdcMem) }() var bih winBitmapInfoHeader bih.BiSize = dibSectionBytes @@ -268,6 +268,16 @@ func decodeColorCursor(hbmColor, hbmMask windows.Handle) (*image.RGBA, error) { g := color[si+1] r := color[si+2] a := pixelAlpha(color[si+3], si, mask, hasAlpha) + // Premultiply so the shared compositor can use the same + // formula on every platform (X11 XFixes and macOS CG return + // premultiplied bytes natively). + if a != 255 && a != 0 { + r = byte(uint32(r) * uint32(a) / 255) + g = byte(uint32(g) * uint32(a) / 255) + b = byte(uint32(b) * uint32(a) / 255) + } else if a == 0 { + r, g, b = 0, 0, 0 + } img.Pix[si+0] = r img.Pix[si+1] = g img.Pix[si+2] = b diff --git a/client/vnc/server/extclipboard.go b/client/vnc/server/extclipboard.go index ba7dba3862d..4f246535d09 100644 --- a/client/vnc/server/extclipboard.go +++ b/client/vnc/server/extclipboard.go @@ -91,8 +91,8 @@ func buildExtClipRequest(formats uint32) []byte { // per the extension spec. Rejects oversized input so a caller bug can't // produce a payload larger than the size advertised in our Caps. func buildExtClipProvideText(text string) ([]byte, error) { - if len(text) > extClipMaxText { - return nil, fmt.Errorf("clipboard text exceeds extClipMaxText (%d > %d)", len(text), extClipMaxText) + if len(text)+1 > extClipMaxText { + return nil, fmt.Errorf("clipboard text exceeds extClipMaxText (%d > %d)", len(text)+1, extClipMaxText) } body := make([]byte, 0, 4+len(text)+1) var lenBuf [4]byte diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_unix.go index 098104678a5..86f6f0148f6 100644 --- a/client/vnc/server/input_uinput_unix.go +++ b/client/vnc/server/input_uinput_unix.go @@ -110,7 +110,7 @@ func NewUInputInjector(w, h int) (*UInputInjector, error) { return nil, fmt.Errorf("UI_SET_KEYBIT %d: %w", key, err) } } - for _, btn := range []uint16{btnLeft, btnRight, btnMiddle} { + for _, btn := range []uint16{btnLeft, btnRight, btnMiddle, btnSide, btnExtra} { if err := setBit(fd, uiSetKeyBit, uint32(btn)); err != nil { unix.Close(fd) return nil, fmt.Errorf("UI_SET_KEYBIT btn %d: %w", btn, err) diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index 385317bd7ac..cf18d96a307 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -134,8 +134,15 @@ type WindowsInputInjector struct { closed chan struct{} closeOnce sync.Once prevButtonMask uint16 - ctrlDown bool - altDown bool + // lastQueuedButtonMask is the most recent buttonMask submitted to ch + // by InjectPointer. Compared against the incoming sample to decide + // whether the new event is move-only (lossy enqueue) or carries a + // button/wheel transition (reliable enqueue). + lastQueuedButtonMask uint16 + lastQueuedMaskValid bool + queueMu sync.Mutex + ctrlDown bool + altDown bool } // NewWindowsInputInjector creates a desktop-aware input injector. @@ -171,6 +178,21 @@ func (w *WindowsInputInjector) tryEnqueue(cmd inputCmd) { } } +// enqueueReliable posts a command and blocks until it's accepted or the +// injector closes. Used for edge-triggered events (button/wheel) where a +// drop would desynchronize prevButtonMask in dispatch(). +func (w *WindowsInputInjector) enqueueReliable(cmd inputCmd) { + select { + case <-w.closed: + return + default: + } + select { + case w.ch <- cmd: + case <-w.closed: + } +} + func (w *WindowsInputInjector) loop() { runtime.LockOSThread() @@ -223,11 +245,22 @@ func (w *WindowsInputInjector) InjectKeyScancode(scancode uint32, keysym uint32, } // InjectPointer queues a pointer event for injection on the input desktop -// thread. Pointer events coalesce: when the channel is full (slow desktop -// switch, hung SendInput), drop the new sample so the read loop never -// blocks. The next mouse event carries fresher position anyway. +// thread. Move-only updates use lossy enqueue (next sample carries fresher +// position anyway), but any sample whose buttonMask differs from the last +// queued mask is enqueued reliably so wheel ticks and button transitions +// can't be dropped under backpressure. func (w *WindowsInputInjector) InjectPointer(buttonMask uint16, x, y, serverW, serverH int) { - w.tryEnqueue(inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH}) + cmd := inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH} + w.queueMu.Lock() + transition := !w.lastQueuedMaskValid || w.lastQueuedButtonMask != buttonMask + w.lastQueuedButtonMask = buttonMask + w.lastQueuedMaskValid = true + w.queueMu.Unlock() + if transition { + w.enqueueReliable(cmd) + return + } + w.tryEnqueue(cmd) } // doInjectKeyScancode injects a key event using the QEMU scancode directly, diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index e7fbc9a2644..a904a3a905c 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -146,7 +146,7 @@ func (x *X11InputInjector) InjectPointer(buttonMask uint16, px, py, serverW, ser for _, b := range buttons { pressed := buttonMask&b.rfbBit != 0 wasPressed := x.lastButtons&b.rfbBit != 0 - if b.x11Btn >= 4 { + if b.x11Btn == 4 || b.x11Btn == 5 { // Scroll: send press+release on each scroll event. if pressed { xtest.FakeInput(x.conn, xproto.ButtonPress, b.x11Btn, 0, x.root, 0, 0, 0) diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 60b7b51a9c1..aff21ec9f84 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -182,6 +182,12 @@ type Server struct { sessionSeq uint64 sessions map[uint64]ActiveSessionInfo sessionConns map[uint64]net.Conn + // acceptedConns tracks every connection between Accept() and handler + // return, including connections still in the connection-header / + // handshake phase that have not yet been registered in sessionConns. + // closeActiveSessions iterates this set so Stop() can interrupt + // handshaking peers, not just post-handshake sessions. + acceptedConns map[net.Conn]struct{} // sessionRecorder, when non-nil, receives a SessionTick periodically // during each VNC session and on session close. The engine wires @@ -219,12 +225,13 @@ type virtualSessionManager interface { // header; the protocol-level VNC password scheme is not supported. func New(capturer ScreenCapturer, injector InputInjector) *Server { return &Server{ - capturer: capturer, - injector: injector, - authorizer: sshauth.NewAuthorizer(), - log: log.WithField("component", "vnc-server"), - sessions: make(map[uint64]ActiveSessionInfo), - sessionConns: make(map[uint64]net.Conn), + capturer: capturer, + injector: injector, + authorizer: sshauth.NewAuthorizer(), + log: log.WithField("component", "vnc-server"), + sessions: make(map[uint64]ActiveSessionInfo), + sessionConns: make(map[uint64]net.Conn), + acceptedConns: make(map[net.Conn]struct{}), } } @@ -256,15 +263,15 @@ func (s *Server) removeSession(id uint64) { delete(s.sessionConns, id) } -// closeActiveSessions closes every active session's connection so the -// per-session serve goroutines unblock from their Read loops and exit. -// Called from Stop to make sure clients see an immediate disconnect when -// the server is brought down, instead of waiting for the OS to reclaim -// the sockets after process exit. +// closeActiveSessions closes every accepted connection so per-connection +// goroutines unblock from their Read loops and exit. Called from Stop to +// make sure clients see an immediate disconnect when the server is brought +// down. Iterates acceptedConns so handshaking connections that have not +// yet registered in sessionConns are also closed. func (s *Server) closeActiveSessions() { s.sessionsMu.Lock() - conns := make([]net.Conn, 0, len(s.sessionConns)) - for _, c := range s.sessionConns { + conns := make([]net.Conn, 0, len(s.acceptedConns)) + for c := range s.acceptedConns { conns = append(conns, c) } s.sessionsMu.Unlock() @@ -273,6 +280,21 @@ func (s *Server) closeActiveSessions() { } } +// trackConn registers a freshly accepted connection so Stop() can close +// it even before the session is registered in sessionConns. +func (s *Server) trackConn(c net.Conn) { + s.sessionsMu.Lock() + s.acceptedConns[c] = struct{}{} + s.sessionsMu.Unlock() +} + +// untrackConn forgets a connection once its handler is returning. +func (s *Server) untrackConn(c net.Conn) { + s.sessionsMu.Lock() + delete(s.acceptedConns, c) + s.sessionsMu.Unlock() +} + // SetServiceMode enables proxy-to-agent mode for Windows service operation. func (s *Server) SetServiceMode(enabled bool) { s.serviceMode = enabled @@ -442,7 +464,11 @@ func (s *Server) acceptLoop() { } enableTCPKeepAlive(conn, s.log) - go s.handleConnection(conn) + s.trackConn(conn) + go func(c net.Conn) { + defer s.untrackConn(c) + s.handleConnection(c) + }(conn) } } diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 161ca7dc66e..8682cc38b5b 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -49,7 +49,11 @@ func (s *Server) serviceAcceptLoop() { enableTCPKeepAlive(conn, s.log) conn = newMetricsConn(conn, s.sessionRecorder) - go s.handleServiceConnectionDarwin(conn, mgr) + s.trackConn(conn) + go func(c net.Conn) { + defer s.untrackConn(c) + s.handleServiceConnectionDarwin(c, mgr) + }(conn) } } diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index ea7785cca02..d47d13839af 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -257,7 +257,11 @@ func (s *Server) serviceAcceptLoop() { enableTCPKeepAlive(conn, s.log) conn = newMetricsConn(conn, s.sessionRecorder) - go s.handleServiceConnection(conn, sm) + s.trackConn(conn) + go func(c net.Conn) { + defer s.untrackConn(c) + s.handleServiceConnection(c, sm) + }(conn) } } diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index c2346a2f418..a8470c655c7 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -171,7 +171,11 @@ func (s *session) applyBackpressure() float64 { base := jpegQualityForLevel(tight.qualityLevel) if base == 0 { - base = tightJPEGQuality + // No client-negotiated quality; let tightQualityFor pick the + // area-based default and skip backpressure adjustments that + // would otherwise lock in a wrong starting point. + tight.jpegQualityOverride = 0 + return frac } q := base if frac > backpressureRampStart { diff --git a/client/vnc/server/session_remote_cursor.go b/client/vnc/server/session_remote_cursor.go index b5bcfc62e78..eaa9302ace9 100644 --- a/client/vnc/server/session_remote_cursor.go +++ b/client/vnc/server/session_remote_cursor.go @@ -59,11 +59,12 @@ func (s *session) maybeCompositeCursor(img *image.RGBA) { compositeCursor(img, cursorImg, posX-hotX, posY-hotY) } -// compositeCursor alpha-blends sprite onto frame at (dstX, dstY) using -// straight (non-premultiplied) alpha. Out-of-bounds destinations are -// clipped. Frames captured by our X11/Windows/macOS paths all advertise -// RGBA with a 255-only alpha channel, so the result keeps the framebuffer -// invariant ("opaque pixels everywhere") that the encoder depends on. +// compositeCursor alpha-blends sprite onto frame at (dstX, dstY). +// sprite is assumed to use premultiplied RGBA, which is what every +// cursorSource implementation in this package produces (X11 XFixes and +// macOS CG return premultiplied bytes natively; the Windows path +// premultiplies during decodeColorCursor). Out-of-bounds destinations are +// clipped. func compositeCursor(frame, sprite *image.RGBA, dstX, dstY int) { fw, fh := frame.Rect.Dx(), frame.Rect.Dy() sw, sh := sprite.Rect.Dx(), sprite.Rect.Dy() @@ -109,10 +110,11 @@ func compositeCursor(frame, sprite *image.RGBA, dstX, dstY int) { frame.Pix[fbOff+2] = sprite.Pix[sOff+2] continue } + // Premultiplied compositing: dst = src + dst*(1-srcA). inv := 255 - a - frame.Pix[fbOff+0] = byte((uint32(sprite.Pix[sOff+0])*a + uint32(frame.Pix[fbOff+0])*inv) / 255) - frame.Pix[fbOff+1] = byte((uint32(sprite.Pix[sOff+1])*a + uint32(frame.Pix[fbOff+1])*inv) / 255) - frame.Pix[fbOff+2] = byte((uint32(sprite.Pix[sOff+2])*a + uint32(frame.Pix[fbOff+2])*inv) / 255) + frame.Pix[fbOff+0] = sprite.Pix[sOff+0] + byte((uint32(frame.Pix[fbOff+0])*inv)/255) + frame.Pix[fbOff+1] = sprite.Pix[sOff+1] + byte((uint32(frame.Pix[fbOff+1])*inv)/255) + frame.Pix[fbOff+2] = sprite.Pix[sOff+2] + byte((uint32(frame.Pix[fbOff+2])*inv)/255) } } } From f37e228cc25bab29ebb9a343cacbd3649160c977 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 17:22:02 +0200 Subject: [PATCH 063/151] Replace magic env-var and subcommand strings with named constants --- client/vnc/server/agent_darwin.go | 4 ++-- client/vnc/server/agent_ipc.go | 5 +++++ client/vnc/server/agent_windows.go | 2 +- client/vnc/server/capture_x11.go | 30 +++++++++++++++++++----------- client/vnc/server/input_x11.go | 8 ++++---- client/vnc/server/virtual_x11.go | 4 ++-- 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index c4aa9423483..31adad5b9c3 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -172,7 +172,7 @@ func spawnAgentForUser(uid uint32, port uint16, token string) error { } cmd := exec.Command( "/bin/launchctl", "asuser", strconv.FormatUint(uint64(uid), 10), - exe, "vnc-agent", "--port", strconv.FormatUint(uint64(port), 10), + exe, vncAgentSubcommand, "--port", strconv.FormatUint(uint64(port), 10), ) cmd.Env = append(os.Environ(), agentTokenEnvVar+"="+token) stderr, err := cmd.StderrPipe() @@ -323,7 +323,7 @@ func argvIsVNCAgent(argv []string, ownBase string) bool { return false } for _, a := range argv[1:] { - if a == "vnc-agent" { + if a == vncAgentSubcommand { return true } } diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index 4253007720a..bc1eab62ffb 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -31,6 +31,11 @@ const ( // like this keep the secret out of the command line, where listings // such as `ps` or Windows tasklist would expose it. agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" // #nosec G101 -- env var name, not a credential + + // vncAgentSubcommand is the CLI subcommand the daemon invokes to start + // the per-session agent process. Must match cmd.vncAgentCmd.Use in + // client/cmd/vnc_agent.go. + vncAgentSubcommand = "vnc-agent" ) // generateAuthToken returns a fresh hex-encoded random token for one diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index dfae07272ef..0e27212e40e 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -352,7 +352,7 @@ func spawnAgentInSession(sessionID uint32, port uint16, authToken string, jobHan return 0, fmt.Errorf("get executable path: %w", err) } - cmdLine := fmt.Sprintf(`"%s" vnc-agent --port %d`, exePath, port) + cmdLine := fmt.Sprintf(`"%s" %s --port %d`, exePath, vncAgentSubcommand, port) cmdLineW, err := windows.UTF16PtrFromString(cmdLine) if err != nil { return 0, fmt.Errorf("UTF16 cmdline: %w", err) diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index fb28d9a125f..c7634bc2eaa 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -19,11 +19,19 @@ import ( "github.com/jezek/xgb/xproto" ) -// x11SocketDir is the well-known directory where X servers create their -// abstract UNIX-domain sockets, named "X". Used both for -// auto-detecting an existing display and for placing/probing sockets of -// virtual sessions we spawn. -const x11SocketDir = "/tmp/.X11-unix" +const ( + // x11SocketDir is the well-known directory where X servers create + // their abstract UNIX-domain sockets, named "X". Used both + // for auto-detecting an existing display and for placing/probing + // sockets of virtual sessions we spawn. + x11SocketDir = "/tmp/.X11-unix" + + // envDisplay is the X11 display selector environment variable. + envDisplay = "DISPLAY" + // envXAuthority points X clients at the cookie file used to + // authenticate against the running X server. + envXAuthority = "XAUTHORITY" +) // X11Capturer captures the screen from an X11 display using the MIT-SHM extension. type X11Capturer struct { @@ -52,7 +60,7 @@ type X11Capturer struct { // environment variables if needed. This is required when running as a system // service where these vars aren't set. func detectX11Display() { - if os.Getenv("DISPLAY") != "" { + if os.Getenv(envDisplay) != "" { return } @@ -115,10 +123,10 @@ func detectX11FromSockets() bool { return false } display := ":" + strconv.Itoa(minDisplay) - os.Setenv("DISPLAY", display) + os.Setenv(envDisplay, display) auth := findXorgAuthFromPS() if auth != "" { - os.Setenv("XAUTHORITY", auth) + os.Setenv(envXAuthority, auth) log.Infof("auto-detected DISPLAY=%s (from socket) XAUTHORITY=%s (from ps)", display, auth) } else { log.Infof("auto-detected DISPLAY=%s (from socket)", display) @@ -167,9 +175,9 @@ func parseXorgArgs(args []string) (display, auth string) { } func setDisplayEnv(display, auth string) { - os.Setenv("DISPLAY", display) + os.Setenv(envDisplay, display) if auth != "" { - os.Setenv("XAUTHORITY", auth) + os.Setenv(envXAuthority, auth) log.Infof("auto-detected DISPLAY=%s XAUTHORITY=%s", display, auth) return } @@ -205,7 +213,7 @@ func splitNull(data []byte) [][]byte { func NewX11Capturer(display string) (*X11Capturer, error) { if display == "" { detectX11Display() - display = os.Getenv("DISPLAY") + display = os.Getenv(envDisplay) } if display == "" { return nil, fmt.Errorf("DISPLAY not set and no Xorg process found") diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index a904a3a905c..ed44aff93ec 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -32,7 +32,7 @@ func NewX11InputInjector(display string) (*X11InputInjector, error) { detectX11Display() if display == "" { - display = os.Getenv("DISPLAY") + display = os.Getenv(envDisplay) } if display == "" { return nil, fmt.Errorf("DISPLAY not set and no Xorg process found") @@ -296,9 +296,9 @@ func (x *X11InputInjector) GetClipboard() string { } func (x *X11InputInjector) clipboardEnv() []string { - env := []string{"DISPLAY=" + x.display} - if auth := os.Getenv("XAUTHORITY"); auth != "" { - env = append(env, "XAUTHORITY="+auth) + env := []string{envDisplay + "=" + x.display} + if auth := os.Getenv(envXAuthority); auth != "" { + env = append(env, envXAuthority+"="+auth) } return env } diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go index 2d3f1cbc726..0a764fca5ca 100644 --- a/client/vnc/server/virtual_x11.go +++ b/client/vnc/server/virtual_x11.go @@ -126,7 +126,7 @@ func (vs *VirtualSession) start() error { // Grant the target user access to the display via xhost. xhostCmd := exec.Command("xhost", "+SI:localuser:"+vs.user.Username) - xhostCmd.Env = []string{"DISPLAY=" + vs.display} + xhostCmd.Env = []string{envDisplay + "=" + vs.display} if out, err := xhostCmd.CombinedOutput(); err != nil { vs.log.Debugf("xhost: %s (%v)", strings.TrimSpace(string(out)), err) } @@ -447,7 +447,7 @@ func (vs *VirtualSession) stopDesktop() { func (vs *VirtualSession) buildUserEnv() []string { return []string{ - "DISPLAY=" + vs.display, + envDisplay + "=" + vs.display, "HOME=" + vs.user.HomeDir, "USER=" + vs.user.Username, "LOGNAME=" + vs.user.Username, From b41d11bbbec260b59c0205d1560be7e8f8fd0caf Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 17:39:07 +0200 Subject: [PATCH 064/151] Allow Cursor pseudo-encoding in session mode and cache last XFixes sprite --- client/vnc/server/cursor_x11.go | 38 +++++++++++++++++++++++---------- client/vnc/server/server.go | 4 ---- client/vnc/server/session.go | 7 ------ 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/client/vnc/server/cursor_x11.go b/client/vnc/server/cursor_x11.go index 2ac8d27984b..a00ddf55c91 100644 --- a/client/vnc/server/cursor_x11.go +++ b/client/vnc/server/cursor_x11.go @@ -17,15 +17,20 @@ import ( type xfixesCursor struct { mu sync.Mutex conn *xgb.Conn - // runtimeErr latches the first GetCursorImage failure so subsequent - // calls return quickly without another X round-trip. Some virtual - // displays advertise XFixes but reject GetCursorImage (Xvfb). - runtimeErr error // lastPosX/lastPosY hold the cursor screen position observed on the // most recent successful GetCursorImage. cursorPositionSource readers // share this value so we do not pay a second X round-trip per frame. lastPosX, lastPosY int hasPos bool + // lastImg, lastHotX, lastHotY, lastSerial cache the most recent good + // GetCursorImage result so transient failures (cursor hidden, server + // briefly unresponsive) reuse the previous sprite instead of going + // dark. Without this the encoder's compositing path drops to no-op as + // soon as the cursor becomes momentarily unavailable. + lastImg *image.RGBA + lastHotX int + lastHotY int + lastSerial uint64 } // newXFixesCursor initialises the XFixes extension on conn. Returns an @@ -42,24 +47,31 @@ func newXFixesCursor(conn *xgb.Conn) (*xfixesCursor, error) { } // Cursor returns the current cursor sprite as RGBA along with its hotspot -// and serial. Callers should treat an unchanged serial as "no update". +// and serial. Callers should treat an unchanged serial as "no update". On +// a transient GetCursorImage failure the last cached sprite is returned +// so compositing keeps painting the cursor instead of disappearing. func (c *xfixesCursor) Cursor() (*image.RGBA, int, int, uint64, error) { c.mu.Lock() defer c.mu.Unlock() - if c.runtimeErr != nil { - return nil, 0, 0, 0, c.runtimeErr - } reply, err := xfixes.GetCursorImage(c.conn).Reply() if err != nil { - c.runtimeErr = fmt.Errorf("xfixes GetCursorImage: %w", err) - return nil, 0, 0, 0, c.runtimeErr + if c.lastImg != nil { + return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil + } + return nil, 0, 0, 0, fmt.Errorf("xfixes GetCursorImage: %w", err) } c.lastPosX, c.lastPosY, c.hasPos = int(reply.X), int(reply.Y), true w, h := int(reply.Width), int(reply.Height) if w <= 0 || h <= 0 { + if c.lastImg != nil { + return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil + } return nil, 0, 0, 0, fmt.Errorf("cursor has zero extent") } if len(reply.CursorImage) < w*h { + if c.lastImg != nil { + return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil + } return nil, 0, 0, 0, fmt.Errorf("cursor pixel buffer truncated: %d < %d", len(reply.CursorImage), w*h) } img := image.NewRGBA(image.Rect(0, 0, w, h)) @@ -72,7 +84,11 @@ func (c *xfixesCursor) Cursor() (*image.RGBA, int, int, uint64, error) { img.Pix[o+2] = byte(p) img.Pix[o+3] = byte(p >> 24) } - return img, int(reply.Xhot), int(reply.Yhot), uint64(reply.CursorSerial), nil + c.lastImg = img + c.lastHotX = int(reply.Xhot) + c.lastHotY = int(reply.Yhot) + c.lastSerial = uint64(reply.CursorSerial) + return img, c.lastHotX, c.lastHotY, c.lastSerial, nil } // Cursor on X11Capturer satisfies cursorSource. The XFixes binding is diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index aff21ec9f84..3be5044cdd6 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -604,10 +604,6 @@ func (s *Server) handleConnection(conn net.Conn) { serverW: capturer.Width(), serverH: capturer.Height(), log: connLog, - // Virtual sessions run on Xvfb which has no usable cursor source, - // so we skip the Cursor pseudo-encoding and let the client's - // local fallback show instead. - disableCursor: header.mode == ModeSession, } sess.serve() } diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index bd355a780d1..495f32c6458 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -99,10 +99,6 @@ type session struct { // source so the encoder stops polling for the rest of the session. // Reset on SetEncodings so a reconnect can retry. cursorSourceFailed bool - // disableCursor suppresses the Cursor pseudo-encoding regardless of - // what the client advertises. Set for virtual sessions where no - // usable cursor source exists. Constant for the session lifetime. - disableCursor bool // showRemoteCursor switches the encoder to compositing the server // cursor sprite into the captured framebuffer at the remote position // instead of emitting the Cursor pseudo-encoding. Toggled by the @@ -462,9 +458,6 @@ func (s *session) applyEncoding(enc int32) string { s.clientSupportsExtClipboard = true return "ext-clipboard" case pseudoEncCursor: - if s.disableCursor { - return "" - } s.clientSupportsCursor = true return "cursor" case pseudoEncExtendedMouseButtons: From ef4ea2e311c9fb1644dc9df6b5221f9ea4af925e Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 18:03:38 +0200 Subject: [PATCH 065/151] Set Fn flag on macOS navigation keycodes so the next key isn't treated as Fn-modified --- client/vnc/server/input_darwin.go | 38 ++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 2e8542aa84d..dd5ebe4c20a 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -36,6 +36,15 @@ const ( kCGHIDEventTap int32 = 0 + // kCGEventFlagMaskSecondaryFn is the CGEventFlags bit Apple sets when + // a key was activated via the Fn modifier on internal keyboards. The + // navigation cluster (ForwardDelete, Home, End, PageUp, PageDown, + // Help/Insert, arrows) lives in the Fn-shifted region of an Apple + // keyboard, so synthesising those keycodes without this bit leaves the + // system in a confused "Fn implied" state where the next plain + // letter is treated as a menu accelerator. + kCGEventFlagMaskSecondaryFn uint64 = 0x00800000 + // kCGMouseEventClickState (event field 1) tells macOS how many // consecutive clicks of this button have happened. Without it, a // double click looks like two independent single clicks and apps @@ -64,6 +73,7 @@ var ( cgEventCreateMouseEvent func(uintptr, int32, float64, float64, int32) uintptr cgEventPost func(int32, uintptr) cgEventSetIntegerValueField func(uintptr, int32, int64) + cgEventSetFlags func(uintptr, uint64) // CGEventCreateScrollWheelEvent is variadic, call via SyscallN. cgEventCreateScrollWheelEventAddr uintptr @@ -125,6 +135,7 @@ func initDarwinInput() { purego.RegisterLibFunc(&cgEventCreateMouseEvent, cg, "CGEventCreateMouseEvent") purego.RegisterLibFunc(&cgEventPost, cg, "CGEventPost") purego.RegisterLibFunc(&cgEventSetIntegerValueField, cg, "CGEventSetIntegerValueField") + purego.RegisterLibFunc(&cgEventSetFlags, cg, "CGEventSetFlags") sym, err := purego.Dlsym(cg, "CGEventCreateScrollWheelEvent") if err == nil { @@ -397,16 +408,41 @@ func (m *MacInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) m.postMacKey(src, vk, down) } -// postMacKey emits a single key down/up event via Core Graphics. +// postMacKey emits a single key down/up event via Core Graphics. The +// Fn flag is attached for keycodes that live in the Fn-shifted region of +// an Apple keyboard so the system doesn't treat the next plain key as +// Fn-modified. func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) { event := cgEventCreateKeyboardEvent(src, keycode, down) if event == 0 { return } + if isFnShiftedKeycode(keycode) && cgEventSetFlags != nil { + cgEventSetFlags(event, kCGEventFlagMaskSecondaryFn) + } cgEventPost(kCGHIDEventTap, event) cfRelease(event) } +// isFnShiftedKeycode reports whether keycode is one of the Apple +// navigation/edit keys that hardware produces with the Fn modifier held. +func isFnShiftedKeycode(keycode uint16) bool { + switch keycode { + case 0x72, // Help / Insert + 0x73, // Home + 0x74, // PageUp + 0x75, // ForwardDelete + 0x77, // End + 0x79, // PageDown + 0x7B, // Left + 0x7C, // Right + 0x7D, // Down + 0x7E: // Up + return true + } + return false +} + // InjectPointer simulates mouse movement and button events. func (m *MacInputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) { wakeDisplay() From 98d533c8e848a1da03de198c9249770be3297bc1 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 21 May 2026 12:01:25 +0200 Subject: [PATCH 066/151] Address CodeRabbit feedback on VNC server agent matching and session lifecycle --- client/vnc/server/agent_darwin.go | 34 ++++++-------- ...t_uinput_unix.go => input_uinput_linux.go} | 2 +- client/vnc/server/input_windows.go | 6 +++ client/vnc/server/scancodes.go | 2 +- client/vnc/server/server.go | 47 ++++++++++--------- client/vnc/server/virtual_x11.go | 15 +++++- 6 files changed, 63 insertions(+), 43 deletions(-) rename client/vnc/server/{input_uinput_unix.go => input_uinput_linux.go} (99%) diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index 31adad5b9c3..da8083bcf64 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -10,7 +10,6 @@ import ( "net" "os" "os/exec" - "path/filepath" "strconv" "sync" "syscall" @@ -253,15 +252,19 @@ func killAllVNCAgents() { } // vncAgentPIDs returns the pids of vnc-agent subprocesses spawned from -// this binary. Matches on (argv[0] basename == our own basename) AND -// argv contains the "vnc-agent" subcommand. Skips pid 0 and 1 defensively. +// this binary. Matches exactly on argv[0] == our own executable path +// AND argv[1] == "vnc-agent" so unrelated processes that happen to have +// the same name elsewhere in argv are not targeted. Skips pid 0 and 1 +// defensively. func vncAgentPIDs() ([]int, error) { procs, err := unix.SysctlKinfoProcSlice("kern.proc.all") if err != nil { return nil, fmt.Errorf("sysctl kern.proc.all: %w", err) } - ownExe, _ := os.Executable() - ownBase := filepath.Base(ownExe) + ownExe, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve own executable: %w", err) + } var out []int for i := range procs { pid := int(procs[i].Proc.P_pid) @@ -269,7 +272,7 @@ func vncAgentPIDs() ([]int, error) { continue } argv, err := procArgv(pid) - if err != nil || !argvIsVNCAgent(argv, ownBase) { + if err != nil || !argvIsVNCAgent(argv, ownExe) { continue } out = append(out, pid) @@ -313,19 +316,12 @@ func procArgv(pid int) ([]string, error) { } // argvIsVNCAgent reports whether argv belongs to a vnc-agent subprocess -// spawned from our binary. Requires argv[0]'s basename to match ownBase -// and the "vnc-agent" subcommand to appear among the positional args. -func argvIsVNCAgent(argv []string, ownBase string) bool { - if len(argv) < 2 || ownBase == "" { - return false - } - if filepath.Base(argv[0]) != ownBase { +// spawned from our binary. Requires argv[0] to match ownExe exactly and +// argv[1] to be the vnc-agent subcommand. Matches the spawn shape in +// spawnAgentForUser and rejects anything else. +func argvIsVNCAgent(argv []string, ownExe string) bool { + if len(argv) < 2 || ownExe == "" { return false } - for _, a := range argv[1:] { - if a == vncAgentSubcommand { - return true - } - } - return false + return argv[0] == ownExe && argv[1] == vncAgentSubcommand } diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_linux.go similarity index 99% rename from client/vnc/server/input_uinput_unix.go rename to client/vnc/server/input_uinput_linux.go index 86f6f0148f6..505fbc880b6 100644 --- a/client/vnc/server/input_uinput_unix.go +++ b/client/vnc/server/input_uinput_linux.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build linux package server diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index cf18d96a307..cf3e2505ed7 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -482,6 +482,7 @@ var ( procGlobalAlloc = kernel32.NewProc("GlobalAlloc") procGlobalLock = kernel32.NewProc("GlobalLock") procGlobalUnlock = kernel32.NewProc("GlobalUnlock") + procGlobalFree = kernel32.NewProc("GlobalFree") ) const ( @@ -522,6 +523,7 @@ func (w *WindowsInputInjector) doSetClipboard(text string) { ptr, _, _ := procGlobalLock.Call(hMem) if ptr == 0 { log.Tracef("GlobalLock for clipboard: lock returned nil") + _, _, _ = procGlobalFree.Call(hMem) return } copy(unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), len(utf16)), utf16) @@ -530,6 +532,7 @@ func (w *WindowsInputInjector) doSetClipboard(text string) { r, _, lerr := procOpenClipboard.Call(0) if r == 0 { log.Tracef("OpenClipboard: %v", lerr) + _, _, _ = procGlobalFree.Call(hMem) return } defer logCleanupCall("CloseClipboard", procCloseClipboard) @@ -538,6 +541,9 @@ func (w *WindowsInputInjector) doSetClipboard(text string) { r, _, lerr = procSetClipboardData.Call(cfUnicodeText, hMem) if r == 0 { log.Tracef("SetClipboardData: %v", lerr) + // Ownership only transfers to the OS on success; on failure we + // still own hMem and must free it. + _, _, _ = procGlobalFree.Call(hMem) } } diff --git a/client/vnc/server/scancodes.go b/client/vnc/server/scancodes.go index 54db42a6352..9ae20480534 100644 --- a/client/vnc/server/scancodes.go +++ b/client/vnc/server/scancodes.go @@ -23,7 +23,7 @@ package server // // Linux KEY_* codes. Only the ones we reference, since the full // linux/input-event-codes.h list isn't useful here. Naming mirrors the -// existing constants in input_uinput_unix.go (mixed case, no underscores). +// existing constants in input_uinput_linux.go (mixed case, no underscores). const ( keyEsc = 1 key1 = 2 diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 3be5044cdd6..7f3b98816d3 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -418,11 +418,15 @@ func (s *Server) Stop() error { s.cancel = nil } - // Close active client connections before tearing down capturers and - // listeners. The per-session serve goroutines unblock from their Read - // loop with an error and run their deferred conn.Close, which surfaces - // a clean disconnect on the client side instead of leaving the - // connection hanging until the OS reclaims it on process exit. + // Close the listener first so the accept loop exits and cannot + // register any further connections in acceptedConns. Then close every + // already-accepted connection so per-session serve goroutines unblock + // and run their deferred conn.Close. + var listenerErr error + if s.listener != nil { + listenerErr = s.listener.Close() + s.listener = nil + } s.closeActiveSessions() if s.vmgr != nil { @@ -437,12 +441,8 @@ func (s *Server) Stop() error { c.Close() } - if s.listener != nil { - err := s.listener.Close() - s.listener = nil - if err != nil { - return fmt.Errorf("close VNC listener: %w", err) - } + if listenerErr != nil { + return fmt.Errorf("close VNC listener: %w", listenerErr) } s.log.Info("stopped") @@ -894,7 +894,8 @@ func (s *Server) acquireSessionResources(conn net.Conn, header *connectionHeader case ModeSession: return s.acquireVirtualSession(conn, header, connLog) default: - return s.acquireAttachSession(), s.injector, attachSessionCleanup, true + capturer, cleanup := s.acquireAttachSession() + return capturer, s.injector, cleanup, true } } @@ -921,17 +922,21 @@ func (s *Server) acquireVirtualSession(conn net.Conn, header *connectionHeader, return vs.Capturer(), vs.Injector(), vs.ClientDisconnect, true } -func (s *Server) acquireAttachSession() ScreenCapturer { - if cc, ok := s.capturer.(interface{ ClientConnect() }); ok { +// acquireAttachSession bumps the shared capturer's per-session refcount +// (if it implements the optional ClientConnect/ClientDisconnect pair) and +// returns a cleanup func that releases it. X11Poller and the Windows +// capturer rely on the disconnect path to drop SHM/DXGI resources when no +// client is active. +func (s *Server) acquireAttachSession() (ScreenCapturer, func()) { + type connectDisconnect interface { + ClientConnect() + ClientDisconnect() + } + if cc, ok := s.capturer.(connectDisconnect); ok { cc.ClientConnect() + return s.capturer, cc.ClientDisconnect } - return s.capturer -} - -// attachSessionCleanup is the no-op cleanup used by attach mode. Returned as a -// named func rather than an inline closure so the empty body is unambiguous. -func attachSessionCleanup() { - // Attach mode keeps the shared capturer; nothing to release per session. + return s.capturer, func() {} } // modeString returns a human-readable session mode name. diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go index 0a764fca5ca..9f8886bc299 100644 --- a/client/vnc/server/virtual_x11.go +++ b/client/vnc/server/virtual_x11.go @@ -176,10 +176,19 @@ func (vs *VirtualSession) ClientDisconnect() { // idleExpired is called by the idle timer. It stops the session and // notifies the session manager via onIdle so it removes us from the map. +// Bails out early if a client reconnected before the timer callback won +// the race (Stop() doesn't cancel an already-firing AfterFunc, so the +// state check has to happen here under vs.mu). func (vs *VirtualSession) idleExpired() { + vs.mu.Lock() + if vs.stopped || vs.clients > 0 { + vs.mu.Unlock() + return + } + vs.mu.Unlock() + vs.log.Info("idle timeout reached, destroying virtual session") vs.Stop() - // onIdle acquires sessionManager.mu; safe because Stop() has released vs.mu. if vs.onIdle != nil { vs.onIdle() } @@ -231,6 +240,10 @@ func (vs *VirtualSession) Stop() { if vs.injector != nil { vs.injector.Close() } + if vs.poller != nil { + vs.poller.Close() + vs.poller = nil + } vs.stopDesktop() vs.stopXvfb() From 2f4ddf0796fde1ceb40f66fbfdc97fac490c9779 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 21 May 2026 12:30:14 +0200 Subject: [PATCH 067/151] Emit explicit Fn flagsChanged transitions around macOS navigation keys --- client/vnc/server/input_darwin.go | 51 ++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index dd5ebe4c20a..a144ae4e29e 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -27,6 +27,7 @@ const ( kCGEventRightMouseDragged int32 = 7 kCGEventKeyDown int32 = 10 kCGEventKeyUp int32 = 11 + kCGEventFlagsChanged int32 = 12 kCGEventOtherMouseDown int32 = 25 kCGEventOtherMouseUp int32 = 26 @@ -74,6 +75,8 @@ var ( cgEventPost func(int32, uintptr) cgEventSetIntegerValueField func(uintptr, int32, int64) cgEventSetFlags func(uintptr, uint64) + cgEventSetType func(uintptr, int32) + cgEventCreateForInput func(uintptr) uintptr // CGEventCreateScrollWheelEvent is variadic, call via SyscallN. cgEventCreateScrollWheelEventAddr uintptr @@ -136,6 +139,8 @@ func initDarwinInput() { purego.RegisterLibFunc(&cgEventPost, cg, "CGEventPost") purego.RegisterLibFunc(&cgEventSetIntegerValueField, cg, "CGEventSetIntegerValueField") purego.RegisterLibFunc(&cgEventSetFlags, cg, "CGEventSetFlags") + purego.RegisterLibFunc(&cgEventSetType, cg, "CGEventSetType") + purego.RegisterLibFunc(&cgEventCreateForInput, cg, "CGEventCreate") sym, err := purego.Dlsym(cg, "CGEventCreateScrollWheelEvent") if err == nil { @@ -408,20 +413,56 @@ func (m *MacInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) m.postMacKey(src, vk, down) } -// postMacKey emits a single key down/up event via Core Graphics. The -// Fn flag is attached for keycodes that live in the Fn-shifted region of -// an Apple keyboard so the system doesn't treat the next plain key as -// Fn-modified. +// postMacKey emits a single key down/up event via Core Graphics. For +// keycodes that live in the Fn-shifted region of an Apple keyboard we +// also emit explicit flagsChanged events around the keypress: posting +// the Fn flag on the key event alone leaves macOS's modifier state +// machine without a matching transition, which manifests as "Fn stays +// active" for the next key (e.g. the next letter activates a menu +// accelerator). func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) { + fnShifted := isFnShiftedKeycode(keycode) + if fnShifted && down { + postFnFlagsChanged(src, true) + } event := cgEventCreateKeyboardEvent(src, keycode, down) if event == 0 { + if fnShifted && !down { + postFnFlagsChanged(src, false) + } return } - if isFnShiftedKeycode(keycode) && cgEventSetFlags != nil { + if fnShifted && cgEventSetFlags != nil { cgEventSetFlags(event, kCGEventFlagMaskSecondaryFn) } cgEventPost(kCGHIDEventTap, event) cfRelease(event) + if fnShifted && !down { + postFnFlagsChanged(src, false) + } +} + +// postFnFlagsChanged emits a synthetic Fn modifier transition so the +// system updates its global modifier state to match the key events we +// post for the navigation cluster. Without this, posting a Fn-flagged +// key event leaves macOS thinking Fn is still held after the key is +// released. +func postFnFlagsChanged(src uintptr, fnOn bool) { + if cgEventCreateForInput == nil || cgEventSetType == nil || cgEventSetFlags == nil { + return + } + event := cgEventCreateForInput(src) + if event == 0 { + return + } + cgEventSetType(event, kCGEventFlagsChanged) + var flags uint64 + if fnOn { + flags = kCGEventFlagMaskSecondaryFn + } + cgEventSetFlags(event, flags) + cgEventPost(kCGHIDEventTap, event) + cfRelease(event) } // isFnShiftedKeycode reports whether keycode is one of the Apple From 3d3055dc7f7b7b91ab9bbd64a19adcdc15c7b084 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 21 May 2026 16:49:47 +0200 Subject: [PATCH 068/151] Replace VNC JWT auth with a Noise_IK handshake bound to ACL-pushed pubkeys --- client/cmd/vnc_agent.go | 12 +- client/internal/engine.go | 2 +- client/internal/engine_vnc.go | 74 +- client/internal/engine_vnc_stub.go | 2 +- client/proto/daemon.pb.go | 14 +- client/proto/daemon.proto | 4 +- client/server/server.go | 2 +- client/ssh/auth/auth.go | 81 +- client/ssh/auth/auth_test.go | 59 + client/ssh/server/executor_windows.go | 4 +- client/ssh/server/server.go | 44 +- client/status/status.go | 8 +- client/vnc/server/noise_auth_test.go | 431 ++++++ client/vnc/server/server.go | 480 ++++--- client/vnc/server/server_darwin.go | 18 +- client/vnc/server/server_test.go | 132 +- client/vnc/server/server_windows.go | 28 +- client/vnc/server/session.go | 6 +- client/vnc/server/session_encode.go | 4 + client/wasm/cmd/main.go | 59 +- client/wasm/internal/vnc/proxy.go | 252 +++- go.mod | 1 + go.sum | 4 + .../internals/shared/grpc/conversion.go | 38 +- .../http/handlers/peers/peers_handler.go | 10 +- management/server/policy_test.go | 32 +- management/server/types/account.go | 4 +- management/server/types/network.go | 1 + .../server/types/networkmap_components.go | 3 + .../server/types/policy_authorized_users.go | 18 + management/server/types/policyrule.go | 10 +- shared/auth/jwt/token_age.go | 68 - shared/management/http/api/openapi.yml | 9 + shared/management/http/api/types.gen.go | 6 + shared/management/proto/management.pb.go | 1193 +++++++++-------- shared/management/proto/management.proto | 21 +- 36 files changed, 2015 insertions(+), 1119 deletions(-) create mode 100644 client/vnc/server/noise_auth_test.go delete mode 100644 shared/auth/jwt/token_age.go diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go index 15605784db9..2ca82c0e78b 100644 --- a/client/cmd/vnc_agent.go +++ b/client/cmd/vnc_agent.go @@ -39,12 +39,22 @@ var vncAgentCmd = &cobra.Command{ if token == "" { return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service") } + // Drop the token from our process environment so any child the + // agent spawns does not inherit it, and casual debugging tools + // that dump /proc//environ (or the Windows equivalent) on a + // running agent don't surface the loopback shared secret. + if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil { + log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err) + } capturer, injector, err := newAgentResources() if err != nil { return err } - srv := vncserver.New(capturer, injector) + // The per-user agent listens only on loopback and is gated by an + // agent token shared with the daemon, so no X25519 identity key + // is needed; auth is disabled at the RFB layer. + srv := vncserver.New(capturer, injector, nil) srv.SetDisableAuth(true) srv.SetAgentToken(token) diff --git a/client/internal/engine.go b/client/internal/engine.go index 9d89ee063a5..98d4b9fb6b3 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1064,7 +1064,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { } } - if err := e.updateVNC(conf.GetSshConfig()); err != nil { + if err := e.updateVNC(); err != nil { log.Warnf("failed handling VNC server setup: %v", err) } diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 44bb3ea2ef5..a37e4da87cd 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -66,8 +66,7 @@ func (e *Engine) cleanupVNCPortRedirection() error { } // updateVNC handles starting/stopping the VNC server based on the config flag. -// sshConf provides the JWT identity provider config (shared with SSH). -func (e *Engine) updateVNC(sshConf *mgmProto.SSHConfig) error { +func (e *Engine) updateVNC() error { if !e.config.ServerVNCAllowed { if e.vncSrv != nil { log.Info("VNC server disabled, stopping") @@ -81,15 +80,13 @@ func (e *Engine) updateVNC(sshConf *mgmProto.SSHConfig) error { } if e.vncSrv != nil { - // Update JWT config on existing server in case management sent new config. - e.updateVNCServerJWT(sshConf) return nil } - return e.startVNCServer(sshConf) + return e.startVNCServer() } -func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { +func (e *Engine) startVNCServer() error { if e.wgInterface == nil { return errors.New("wg interface not initialized") } @@ -102,7 +99,7 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { netbirdIP := e.wgInterface.Address().IP - srv := vncserver.New(capturer, injector) + srv := vncserver.New(capturer, injector, e.config.WgPrivateKey[:]) if e.clientMetrics != nil { srv.SetSessionRecorder(func(t vncserver.SessionTick) { e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{ @@ -122,20 +119,6 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { srv.SetServiceMode(true) } - if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil { - audiences := protoJWT.GetAudiences() - if len(audiences) == 0 && protoJWT.GetAudience() != "" { - audiences = []string{protoJWT.GetAudience()} - } - srv.SetJWTConfig(&vncserver.JWTConfig{ - Issuer: protoJWT.GetIssuer(), - Audiences: audiences, - KeysLocation: protoJWT.GetKeysLocation(), - MaxTokenAge: protoJWT.GetMaxTokenAge(), - }) - log.Debugf("VNC: JWT authentication configured (issuer=%s)", protoJWT.GetIssuer()) - } - if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { srv.SetNetstackNet(netstackNet) } @@ -165,35 +148,6 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { return nil } -// updateVNCServerJWT configures the JWT validation for the VNC server using -// the same JWT config as SSH (same identity provider). -func (e *Engine) updateVNCServerJWT(sshConf *mgmProto.SSHConfig) { - if e.vncSrv == nil { - return - } - - vncSrv, ok := e.vncSrv.(*vncserver.Server) - if !ok { - return - } - - protoJWT := sshConf.GetJwtConfig() - if protoJWT == nil { - return - } - - audiences := protoJWT.GetAudiences() - if len(audiences) == 0 && protoJWT.GetAudience() != "" { - audiences = []string{protoJWT.GetAudience()} - } - - vncSrv.SetJWTConfig(&vncserver.JWTConfig{ - Issuer: protoJWT.GetIssuer(), - Audiences: audiences, - KeysLocation: protoJWT.GetKeysLocation(), - MaxTokenAge: protoJWT.GetMaxTokenAge(), - }) -} // updateVNCServerAuth updates VNC fine-grained access control from management. func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { @@ -221,10 +175,28 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { machineUsers[osUser] = indexes.GetIndexes() } + sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys())) + for _, e := range vncAuth.GetSessionPubKeys() { + pub := e.GetPubKey() + if len(pub) != 32 { + log.Warnf("VNC session pubkey wrong length %d", len(pub)) + continue + } + hash := e.GetUserIdHash() + if len(hash) != 16 { + log.Warnf("VNC session user id hash wrong length %d", len(hash)) + continue + } + sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{ + PubKey: pub, + UserIDHash: sshuserhash.UserIDHash(hash), + }) + } + vncSrv.UpdateVNCAuth(&sshauth.Config{ - UserIDClaim: vncAuth.GetUserIDClaim(), AuthorizedUsers: authorizedUsers, MachineUsers: machineUsers, + SessionPubKeys: sessionPubKeys, }) } diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go index 505c308db20..4c8d7cd5578 100644 --- a/client/internal/engine_vnc_stub.go +++ b/client/internal/engine_vnc_stub.go @@ -8,7 +8,7 @@ import ( type vncServer interface{} -func (e *Engine) updateVNC(_ *mgmProto.SSHConfig) error { return nil } +func (e *Engine) updateVNC() error { return nil } func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) { // no-op on platforms without a VNC server diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index ad4353c9132..ec30f1ede4d 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2107,7 +2107,9 @@ type VNCSessionInfo struct { RemoteAddress string `protobuf:"bytes,1,opt,name=remoteAddress,proto3" json:"remoteAddress,omitempty"` Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"` Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` - JwtUsername string `protobuf:"bytes,4,opt,name=jwtUsername,proto3" json:"jwtUsername,omitempty"` + // userID is the Noise-verified session identity (hashed user ID from + // the ACL session-key entry), empty when auth is disabled. + UserID string `protobuf:"bytes,4,opt,name=userID,proto3" json:"userID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2163,9 +2165,9 @@ func (x *VNCSessionInfo) GetUsername() string { return "" } -func (x *VNCSessionInfo) GetJwtUsername() string { +func (x *VNCSessionInfo) GetUserID() string { if x != nil { - return x.JwtUsername + return x.UserID } return "" } @@ -6582,12 +6584,12 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\x88\x01\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"~\n" + "\x0eVNCSessionInfo\x12$\n" + "\rremoteAddress\x18\x01 \x01(\tR\rremoteAddress\x12\x12\n" + "\x04mode\x18\x02 \x01(\tR\x04mode\x12\x1a\n" + - "\busername\x18\x03 \x01(\tR\busername\x12 \n" + - "\vjwtUsername\x18\x04 \x01(\tR\vjwtUsername\"^\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x16\n" + + "\x06userID\x18\x04 \x01(\tR\x06userID\"^\n" + "\x0eVNCServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + "\bsessions\x18\x02 \x03(\v2\x16.daemon.VNCSessionInfoR\bsessions\"\xef\x04\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 6c002b35363..d0e72021061 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -403,7 +403,9 @@ message VNCSessionInfo { string remoteAddress = 1; string mode = 2; string username = 3; - string jwtUsername = 4; + // userID is the Noise-verified session identity (hashed user ID from + // the ACL session-key entry), empty when auth is disabled. + string userID = 4; } // VNCServerState contains the latest state of the VNC server diff --git a/client/server/server.go b/client/server/server.go index d53a0471baa..ac6b142874c 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1199,7 +1199,7 @@ func (s *Server) getVNCServerState() *proto.VNCServerState { RemoteAddress: sess.RemoteAddress, Mode: sess.Mode, Username: sess.Username, - JwtUsername: sess.JWTUsername, + UserID: sess.UserID, }) } return &proto.VNCServerState{ diff --git a/client/ssh/auth/auth.go b/client/ssh/auth/auth.go index 079282fdce4..52a548a8111 100644 --- a/client/ssh/auth/auth.go +++ b/client/ssh/auth/auth.go @@ -15,13 +15,16 @@ const ( DefaultUserIDClaim = "sub" // Wildcard is a special user ID that matches all users Wildcard = "*" + // sessionPubKeyLen is the size of an X25519 static public key in bytes. + sessionPubKeyLen = 32 ) var ( - ErrEmptyUserID = errors.New("JWT user ID is empty") - ErrUserNotAuthorized = errors.New("user is not authorized to access this peer") - ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user") - ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user") + ErrEmptyUserID = errors.New("JWT user ID is empty") + ErrUserNotAuthorized = errors.New("user is not authorized to access this peer") + ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user") + ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user") + ErrSessionKeyNotKnown = errors.New("session pubkey not registered") ) // Authorizer handles SSH fine-grained access control authorization @@ -35,6 +38,12 @@ type Authorizer struct { // machineUsers maps OS login usernames to lists of authorized user indexes machineUsers map[string][]uint32 + // sessionPubKeys maps an X25519 static public key (as map-safe + // array) to the hashed user identity that key authenticates as. + // Populated from management's temporary-access flow; used by VNC to + // authenticate via the Noise_IK handshake. + sessionPubKeys map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash + // mu protects the list of users mu sync.RWMutex } @@ -50,13 +59,25 @@ type Config struct { // MachineUsers maps OS login usernames to indexes in AuthorizedUsers // If a user wants to login as a specific OS user, their index must be in the corresponding list MachineUsers map[string][]uint32 + + // SessionPubKeys binds ephemeral X25519 static public keys to hashed + // user identities. Populated for VNC; ignored on the SSH side. + SessionPubKeys []SessionPubKey +} + +// SessionPubKey is a single ephemeral-key entry: the 32-byte X25519 +// static public key plus the hashed user identity it authenticates as. +type SessionPubKey struct { + PubKey []byte + UserIDHash sshuserhash.UserIDHash } // NewAuthorizer creates a new SSH authorizer with empty configuration func NewAuthorizer() *Authorizer { a := &Authorizer{ - userIDClaim: DefaultUserIDClaim, - machineUsers: make(map[string][]uint32), + userIDClaim: DefaultUserIDClaim, + machineUsers: make(map[string][]uint32), + sessionPubKeys: make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash), } return a @@ -72,6 +93,7 @@ func (a *Authorizer) Update(config *Config) { a.userIDClaim = DefaultUserIDClaim a.authorizedUsers = []sshuserhash.UserIDHash{} a.machineUsers = make(map[string][]uint32) + a.sessionPubKeys = make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash) log.Info("SSH authorization cleared") return } @@ -94,8 +116,19 @@ func (a *Authorizer) Update(config *Config) { } a.machineUsers = machineUsers - log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings", - len(config.AuthorizedUsers), len(machineUsers)) + sessionPubKeys := make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash, len(config.SessionPubKeys)) + for _, e := range config.SessionPubKeys { + if len(e.PubKey) != sessionPubKeyLen { + continue + } + var key [sessionPubKeyLen]byte + copy(key[:], e.PubKey) + sessionPubKeys[key] = e.UserIDHash + } + a.sessionPubKeys = sessionPubKeys + + log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings, %d session pubkeys", + len(config.AuthorizedUsers), len(machineUsers), len(sessionPubKeys)) } // Authorize validates if a user is authorized to login as the specified OS user. @@ -155,6 +188,38 @@ func (a *Authorizer) GetUserIDClaim() string { return a.userIDClaim } +// LookupSessionKey resolves a Noise-verified static public key to the +// hashed user identity registered with it. Fails closed when the key is +// unknown. +func (a *Authorizer) LookupSessionKey(pubKey []byte) (sshuserhash.UserIDHash, error) { + var zero sshuserhash.UserIDHash + if len(pubKey) != sessionPubKeyLen { + return zero, fmt.Errorf("session pubkey wrong length: %d", len(pubKey)) + } + var key [sessionPubKeyLen]byte + copy(key[:], pubKey) + a.mu.RLock() + hash, ok := a.sessionPubKeys[key] + a.mu.RUnlock() + if !ok { + return zero, ErrSessionKeyNotKnown + } + return hash, nil +} + +// AuthorizeOSUserBySessionKey resolves the OS-user mapping for a session +// key. Mirrors Authorize but skips the JWT-hash step since the key has +// already been verified and the user identity hash is in hand. +func (a *Authorizer) AuthorizeOSUserBySessionKey(userIDHash sshuserhash.UserIDHash, osUsername string) (string, error) { + a.mu.RLock() + defer a.mu.RUnlock() + userIndex, found := a.findUserIndex(userIDHash) + if !found { + return "", fmt.Errorf("session user (hash: %s) not in authorized list for OS user %q: %w", userIDHash, osUsername, ErrUserNotAuthorized) + } + return a.checkMachineUserMapping("session", osUsername, userIndex) +} + // findUserIndex finds the index of a hashed user ID in the authorized users list // Returns the index and true if found, 0 and false if not found func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) { diff --git a/client/ssh/auth/auth_test.go b/client/ssh/auth/auth_test.go index fa27b72e886..87047bb2b81 100644 --- a/client/ssh/auth/auth_test.go +++ b/client/ssh/auth/auth_test.go @@ -1,6 +1,7 @@ package auth import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -610,3 +611,61 @@ func TestAuthorizer_Wildcard_WithPartialIndexes_AllowsAllUsers(t *testing.T) { assert.Error(t, err) assert.ErrorIs(t, err, ErrUserNotAuthorized, "unauthorized user should be denied") } + +func TestAuthorizer_LookupSessionKey_Valid(t *testing.T) { + pub := bytesRepeat(0x11, sessionPubKeyLen) + userHash, err := sshauth.HashUserID("alice") + require.NoError(t, err) + + a := NewAuthorizer() + a.Update(&Config{ + AuthorizedUsers: []sshauth.UserIDHash{userHash}, + MachineUsers: map[string][]uint32{Wildcard: {0}}, + SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}}, + }) + + got, err := a.LookupSessionKey(pub) + require.NoError(t, err) + assert.Equal(t, userHash, got) + + if _, err := a.AuthorizeOSUserBySessionKey(got, "alice"); err != nil { + t.Fatalf("AuthorizeOSUserBySessionKey: %v", err) + } +} + +func TestAuthorizer_LookupSessionKey_UnknownPub(t *testing.T) { + a := NewAuthorizer() + a.Update(&Config{}) + _, err := a.LookupSessionKey(bytesRepeat(0x22, sessionPubKeyLen)) + require.ErrorIs(t, err, ErrSessionKeyNotKnown) +} + +func TestAuthorizer_LookupSessionKey_WrongLength(t *testing.T) { + a := NewAuthorizer() + _, err := a.LookupSessionKey([]byte("short")) + require.Error(t, err) +} + +func TestAuthorizer_LookupSessionKey_UpdateClears(t *testing.T) { + pub := bytesRepeat(0x33, sessionPubKeyLen) + userHash, err := sshauth.HashUserID("alice") + require.NoError(t, err) + + a := NewAuthorizer() + a.Update(&Config{SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}}}) + if _, err := a.LookupSessionKey(pub); err != nil { + t.Fatalf("setup lookup: %v", err) + } + a.Update(&Config{}) + if _, err := a.LookupSessionKey(pub); !errors.Is(err, ErrSessionKeyNotKnown) { + t.Fatalf("expected ErrSessionKeyNotKnown, got %v", err) + } +} + +func bytesRepeat(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} diff --git a/client/ssh/server/executor_windows.go b/client/ssh/server/executor_windows.go index 4053170d28f..51c995ec3cb 100644 --- a/client/ssh/server/executor_windows.go +++ b/client/ssh/server/executor_windows.go @@ -200,8 +200,8 @@ func newLsaString(s string) lsaString { } } -// generateS4UUserToken creates a Windows token using S4U authentication. -// This is the same approach OpenSSH for Windows uses for public key authentication. +// generateS4UUserToken creates a Windows token using S4U authentication +// This is the exact approach OpenSSH for Windows uses for public key authentication func generateS4UUserToken(logger *log.Entry, username, domain string) (windows.Handle, error) { userCpn := buildUserCpn(username, domain) diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index a5f1effb160..6735e0f3bc0 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -551,7 +551,27 @@ func (s *Server) checkTokenAge(token *gojwt.Token, jwtConfig *JWTConfig) error { maxTokenAge = DefaultJWTMaxTokenAge } - return jwt.CheckTokenAge(token, time.Duration(maxTokenAge)*time.Second) + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + userID := extractUserID(token) + return fmt.Errorf("token has invalid claims format (user=%s)", userID) + } + + iat, ok := claims["iat"].(float64) + if !ok { + userID := extractUserID(token) + return fmt.Errorf("token missing iat claim (user=%s)", userID) + } + + issuedAt := time.Unix(int64(iat), 0) + tokenAge := time.Since(issuedAt) + maxAge := time.Duration(maxTokenAge) * time.Second + if tokenAge > maxAge { + userID := getUserIDFromClaims(claims) + return fmt.Errorf("token expired for user=%s: age=%v, max=%v", userID, tokenAge, maxAge) + } + + return nil } func (s *Server) extractAndValidateUser(token *gojwt.Token) (*auth.UserAuth, error) { @@ -582,7 +602,27 @@ func (s *Server) hasSSHAccess(userAuth *auth.UserAuth) bool { } func extractUserID(token *gojwt.Token) string { - return jwt.UserIDFromToken(token) + if token == nil { + return "unknown" + } + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + return "unknown" + } + return getUserIDFromClaims(claims) +} + +func getUserIDFromClaims(claims gojwt.MapClaims) string { + if sub, ok := claims["sub"].(string); ok && sub != "" { + return sub + } + if userID, ok := claims["user_id"].(string); ok && userID != "" { + return userID + } + if email, ok := claims["email"].(string); ok && email != "" { + return email + } + return "unknown" } func (s *Server) parseTokenWithoutValidation(tokenString string) (map[string]interface{}, error) { diff --git a/client/status/status.go b/client/status/status.go index e599727f833..d3aaadf51f1 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -135,7 +135,7 @@ type VNCSessionOutput struct { RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"` Mode string `json:"mode" yaml:"mode"` Username string `json:"username,omitempty" yaml:"username,omitempty"` - JWTUsername string `json:"jwtUsername,omitempty" yaml:"jwtUsername,omitempty"` + UserID string `json:"userID,omitempty" yaml:"userID,omitempty"` } type VNCServerStateOutput struct { @@ -296,7 +296,7 @@ func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput { RemoteAddress: sess.GetRemoteAddress(), Mode: sess.GetMode(), Username: sess.GetUsername(), - JWTUsername: sess.GetJwtUsername(), + UserID: sess.GetUserID(), }) } return VNCServerStateOutput{ @@ -583,9 +583,9 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS if showSSHSessions && vncSessionCount > 0 { for _, sess := range o.VNCServerState.Sessions { var line string - if sess.JWTUsername != "" { + if sess.UserID != "" { line = fmt.Sprintf("[%s@%s -> %s] mode=%s", - sess.JWTUsername, sess.RemoteAddress, sess.Username, sess.Mode) + sess.UserID, sess.RemoteAddress, sess.Username, sess.Mode) } else { line = fmt.Sprintf("[%s] mode=%s user=%s", sess.RemoteAddress, sess.Mode, sess.Username) diff --git a/client/vnc/server/noise_auth_test.go b/client/vnc/server/noise_auth_test.go new file mode 100644 index 00000000000..2da2d817b12 --- /dev/null +++ b/client/vnc/server/noise_auth_test.go @@ -0,0 +1,431 @@ +//go:build !js && !ios && !android + +package server + +import ( + "encoding/binary" + "io" + "net" + "net/netip" + "testing" + "time" + + "github.com/flynn/noise" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/curve25519" + + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +// noiseTestServer starts a VNC server with a freshly generated identity +// key and returns the listener address, the server, and the server's +// static public key for client-side handshake setup. +func noiseTestServer(t *testing.T) (net.Addr, *Server, []byte) { + t.Helper() + + kp, err := noise.DH25519.GenerateKeypair(nil) + require.NoError(t, err) + + srv := New(&testCapturer{}, &StubInputInjector{}, kp.Private) + srv.SetDisableAuth(false) + + addr := netip.MustParseAddrPort("127.0.0.1:0") + network := netip.MustParsePrefix("127.0.0.0/8") + require.NoError(t, srv.Start(t.Context(), addr, network)) + srv.localAddr = netip.MustParseAddr("10.99.99.1") + t.Cleanup(func() { _ = srv.Stop() }) + + return srv.listener.Addr(), srv, kp.Public +} + +// registerSessionKey enrolls a fresh X25519 keypair under the given user +// ID into the server's authorizer with the requested OS-user wildcard +// mapping. Returns the keypair so the test can drive the handshake. +func registerSessionKey(t *testing.T, srv *Server, userID string) noise.DHKey { + t.Helper() + + kp, err := noise.DH25519.GenerateKeypair(nil) + require.NoError(t, err) + + userHash, err := sshuserhash.HashUserID(userID) + require.NoError(t, err) + + srv.UpdateVNCAuth(&sshauth.Config{ + AuthorizedUsers: []sshuserhash.UserIDHash{userHash}, + MachineUsers: map[string][]uint32{sshauth.Wildcard: {0}}, + SessionPubKeys: []sshauth.SessionPubKey{ + {PubKey: kp.Public, UserIDHash: userHash}, + }, + }) + return kp +} + +// writeHeaderPrefix writes the mode + zero-length-username prefix that +// precedes the optional Noise handshake in the NetBird VNC header. +func writeHeaderPrefix(t *testing.T, conn net.Conn, mode byte) { + t.Helper() + prefix := []byte{mode, 0, 0} + _, err := conn.Write(prefix) + require.NoError(t, err) +} + +// writeHeaderTail writes the sessionID/width/height fields that follow +// either the Noise msg2 (auth path) or the prefix alone (no-auth path). +func writeHeaderTail(t *testing.T, conn net.Conn) { + t.Helper() + tail := make([]byte, 8) + _, err := conn.Write(tail) + require.NoError(t, err) +} + +// performInitiator drives the initiator side of Noise_IK against the +// server's identity public key, returns the resulting state. The Noise +// msg2 produced by the server is read and consumed. +func performInitiator(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte) { + t.Helper() + + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: true, + StaticKeypair: clientKey, + PeerStatic: serverPub, + }) + require.NoError(t, err) + + msg1, _, _, err := state.WriteMessage(nil, nil) + require.NoError(t, err) + require.Equal(t, noiseInitiatorMsgLen, len(msg1)) + + _, err = conn.Write(append([]byte("NBV3"), msg1...)) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + msg2 := make([]byte, noiseResponderMsgLen) + _, err = io.ReadFull(conn, msg2) + require.NoError(t, err) + _, _, _, err = state.ReadMessage(nil, msg2) + require.NoError(t, err, "server responder message must decrypt with the correct peer static") +} + +// readRFBFailure consumes the RFB version exchange and returns the +// security-failure reason string. Fails the test if the server did not +// send a failure (i.e. produced a non-zero security-types list). +func readRFBFailure(t *testing.T, conn net.Conn) string { + t.Helper() + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + + var ver [12]byte + _, err := io.ReadFull(conn, ver[:]) + require.NoError(t, err) + require.Equal(t, "RFB 003.008\n", string(ver[:])) + + _, err = conn.Write(ver[:]) + require.NoError(t, err) + + var n [1]byte + _, err = io.ReadFull(conn, n[:]) + require.NoError(t, err) + require.Equal(t, byte(0), n[0], "expected security-failure (0 types)") + + var rl [4]byte + _, err = io.ReadFull(conn, rl[:]) + require.NoError(t, err) + reason := make([]byte, binary.BigEndian.Uint32(rl[:])) + _, err = io.ReadFull(conn, reason) + require.NoError(t, err) + return string(reason) +} + +// readRFBGreetingNoFailure asserts the server proceeded past auth: it +// must offer at least one security type rather than a 0 failure. +func readRFBGreetingNoFailure(t *testing.T, conn net.Conn) { + t.Helper() + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + + var ver [12]byte + _, err := io.ReadFull(conn, ver[:]) + require.NoError(t, err) + require.Equal(t, "RFB 003.008\n", string(ver[:])) + + _, err = conn.Write(ver[:]) + require.NoError(t, err) + + var n [1]byte + _, err = io.ReadFull(conn, n[:]) + require.NoError(t, err) + require.NotEqual(t, byte(0), n[0], "server must offer security types after a valid handshake") +} + +// TestNoise_RegisteredKey_AccessGranted exercises the happy path: a +// session key enrolled in the authorizer completes a Noise_IK handshake +// and the server proceeds to the RFB greeting. +func TestNoise_RegisteredKey_AccessGranted(t *testing.T) { + addr, srv, serverPub := noiseTestServer(t) + clientKey := registerSessionKey(t, srv, "alice@example") + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + writeHeaderPrefix(t, conn, ModeAttach) + performInitiator(t, conn, clientKey, serverPub) + writeHeaderTail(t, conn) + + readRFBGreetingNoFailure(t, conn) +} + +// TestNoise_UnregisteredClientStatic_Rejected proves the authorizer is +// consulted: a syntactically-valid handshake from a key the server has +// never been told about must be rejected fail-closed. +func TestNoise_UnregisteredClientStatic_Rejected(t *testing.T) { + addr, _, serverPub := noiseTestServer(t) + // Auth is enabled but the authorizer was not updated, so the lookup + // path returns ErrSessionKeyNotKnown. + attackerKey, err := noise.DH25519.GenerateKeypair(nil) + require.NoError(t, err) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + writeHeaderPrefix(t, conn, ModeAttach) + performInitiator(t, conn, attackerKey, serverPub) + writeHeaderTail(t, conn) + + reason := readRFBFailure(t, conn) + assert.Contains(t, reason, RejectCodeAuthForbidden) + assert.Contains(t, reason, "session pubkey not registered") +} + +// TestNoise_WrongServerStatic_HandshakeFails proves the server's +// identity is bound into the handshake: an initiator using the wrong +// peer static encrypts msg1 under keys the real server can't derive, so +// the server fails the handshake and closes without RFB output. +func TestNoise_WrongServerStatic_HandshakeFails(t *testing.T) { + addr, srv, _ := noiseTestServer(t) + clientKey := registerSessionKey(t, srv, "alice@example") + + bogusServerKey, err := noise.DH25519.GenerateKeypair(nil) + require.NoError(t, err) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + writeHeaderPrefix(t, conn, ModeAttach) + + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: true, + StaticKeypair: clientKey, + PeerStatic: bogusServerKey.Public, + }) + require.NoError(t, err) + msg1, _, _, err := state.WriteMessage(nil, nil) + require.NoError(t, err) + _, err = conn.Write(append([]byte("NBV3"), msg1...)) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + var b [1]byte + _, err = io.ReadFull(conn, b[:]) + require.Error(t, err, "server must close without RFB greeting when msg1 is sealed for a different server identity") +} + +// TestNoise_MalformedMsg1_ClosesConnection covers the case where the +// magic prefix is correct but the following 96 bytes are random: the +// noise library fails ReadMessage and the server closes silently. +func TestNoise_MalformedMsg1_ClosesConnection(t *testing.T) { + addr, _, _ := noiseTestServer(t) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + writeHeaderPrefix(t, conn, ModeAttach) + junk := make([]byte, noiseInitiatorMsgLen) + for i := range junk { + junk[i] = byte(i) + } + _, err = conn.Write(append([]byte("NBV3"), junk...)) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + var b [1]byte + _, err = io.ReadFull(conn, b[:]) + require.Error(t, err, "garbage msg1 must terminate the connection before any RFB output") +} + +// TestNoise_TruncatedMsg1_ClosesConnection sends fewer than the 96 +// bytes a Noise_IK msg1 must contain. The server's io.ReadFull short- +// reads and closes; no RFB greeting must leak. +func TestNoise_TruncatedMsg1_ClosesConnection(t *testing.T) { + addr, _, _ := noiseTestServer(t) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + + writeHeaderPrefix(t, conn, ModeAttach) + _, err = conn.Write([]byte("NBV3")) + require.NoError(t, err) + _, err = conn.Write(make([]byte, 8)) + require.NoError(t, err) + require.NoError(t, conn.Close()) + + // Re-dial just to confirm the listener is alive (the previous + // connection terminated server-side without affecting the listener). + probe, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + require.NoError(t, probe.Close()) +} + +// TestNoise_AuthEnabled_NoHandshake_Rejected proves that with auth on, +// a connection that skips the Noise prefix (older client / VNC client) +// is rejected with AUTH_FORBIDDEN: identity proof missing. +func TestNoise_AuthEnabled_NoHandshake_Rejected(t *testing.T) { + addr, _, _ := noiseTestServer(t) + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + writeHeaderPrefix(t, conn, ModeAttach) + writeHeaderTail(t, conn) + + reason := readRFBFailure(t, conn) + assert.Contains(t, reason, RejectCodeAuthForbidden) + assert.Contains(t, reason, "identity proof missing") +} + +// TestNoise_RevokedKey_RejectedAfterAuthUpdate verifies the authorizer +// honors revocations: a key that worked before a UpdateVNCAuth call +// must stop working as soon as the new config omits it. +func TestNoise_RevokedKey_RejectedAfterAuthUpdate(t *testing.T) { + addr, srv, serverPub := noiseTestServer(t) + clientKey := registerSessionKey(t, srv, "alice@example") + + // First connection succeeds. + conn1, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn1.Close() + writeHeaderPrefix(t, conn1, ModeAttach) + performInitiator(t, conn1, clientKey, serverPub) + writeHeaderTail(t, conn1) + readRFBGreetingNoFailure(t, conn1) + + // Revoke by pushing a fresh config that drops the pubkey entry. + srv.UpdateVNCAuth(&sshauth.Config{}) + + // Same client, same Noise key, should now be denied. + conn2, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn2.Close() + writeHeaderPrefix(t, conn2, ModeAttach) + performInitiator(t, conn2, clientKey, serverPub) + writeHeaderTail(t, conn2) + + reason := readRFBFailure(t, conn2) + assert.Contains(t, reason, RejectCodeAuthForbidden) + assert.Contains(t, reason, "session pubkey not registered") +} + +// TestNoise_NoIdentityKey_FailsClosed ensures a server constructed +// without a static private key still rejects authenticated connections +// fail-closed; it must not silently accept the client. +func TestNoise_NoIdentityKey_FailsClosed(t *testing.T) { + srv := New(&testCapturer{}, &StubInputInjector{}, nil) + srv.SetDisableAuth(false) + addr := netip.MustParseAddrPort("127.0.0.1:0") + network := netip.MustParsePrefix("127.0.0.0/8") + require.NoError(t, srv.Start(t.Context(), addr, network)) + srv.localAddr = netip.MustParseAddr("10.99.99.1") + t.Cleanup(func() { _ = srv.Stop() }) + + clientKey, err := noise.DH25519.GenerateKeypair(nil) + require.NoError(t, err) + fakeServerKey, err := noise.DH25519.GenerateKeypair(nil) + require.NoError(t, err) + + conn, err := net.Dial("tcp", srv.listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + writeHeaderPrefix(t, conn, ModeAttach) + + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: true, + StaticKeypair: clientKey, + PeerStatic: fakeServerKey.Public, + }) + require.NoError(t, err) + msg1, _, _, err := state.WriteMessage(nil, nil) + require.NoError(t, err) + _, err = conn.Write(append([]byte("NBV3"), msg1...)) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + var b [1]byte + _, err = io.ReadFull(conn, b[:]) + require.Error(t, err, "server without identity key must not write the RFB greeting") +} + +// TestNoise_DerivedIdentityPublicMatchesPrivate sanity-checks the +// derivation done in New(): the identityPublic must be Curve25519. +// Basepoint multiplied with identityKey. +func TestNoise_DerivedIdentityPublicMatchesPrivate(t *testing.T) { + priv := make([]byte, 32) + for i := range priv { + priv[i] = byte(i + 1) + } + srv := New(&testCapturer{}, &StubInputInjector{}, priv) + + expected, err := curve25519.X25519(priv, curve25519.Basepoint) + require.NoError(t, err) + assert.Equal(t, expected, srv.identityPublic) +} + +// TestNoise_SessionMode_OSUserCheckRunsAfterHandshake verifies that a +// successful Noise handshake doesn't bypass OS-user authorization: an +// authenticated key whose user index isn't mapped to the requested OS +// user must be rejected. +func TestNoise_SessionMode_OSUserCheckRunsAfterHandshake(t *testing.T) { + addr, srv, serverPub := noiseTestServer(t) + + clientKey, err := noise.DH25519.GenerateKeypair(nil) + require.NoError(t, err) + userHash, err := sshuserhash.HashUserID("alice@example") + require.NoError(t, err) + + // Map Alice only to "alice" OS user, not the wildcard. + srv.UpdateVNCAuth(&sshauth.Config{ + AuthorizedUsers: []sshuserhash.UserIDHash{userHash}, + MachineUsers: map[string][]uint32{"alice": {0}}, + SessionPubKeys: []sshauth.SessionPubKey{ + {PubKey: clientKey.Public, UserIDHash: userHash}, + }, + }) + + // Request session for "bob" — Noise succeeds, OS-user check denies. + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + bob := []byte("bob") + prefix := []byte{ModeSession, 0, byte(len(bob))} + prefix = append(prefix, bob...) + _, err = conn.Write(prefix) + require.NoError(t, err) + + performInitiator(t, conn, clientKey, serverPub) + writeHeaderTail(t, conn) + + reason := readRFBFailure(t, conn) + assert.Contains(t, reason, RejectCodeAuthForbidden) + assert.Contains(t, reason, "authorize OS user") +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 7f3b98816d3..08e8f8cbe7f 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -3,6 +3,8 @@ package server import ( + "bufio" + "bytes" "context" "crypto/subtle" "encoding/binary" @@ -13,16 +15,15 @@ import ( "io" "net" "net/netip" - "strings" "sync" "time" - gojwt "github.com/golang-jwt/jwt/v5" + "github.com/flynn/noise" log "github.com/sirupsen/logrus" + "golang.org/x/crypto/curve25519" "golang.zx2c4.com/wireguard/tun/netstack" sshauth "github.com/netbirdio/netbird/client/ssh/auth" - nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" ) // Connection modes sent by the client in the session header. @@ -35,11 +36,7 @@ const ( // stable so clients can branch on them without parsing free text. // Format: "CODE: human message". const ( - RejectCodeJWTMissing = "AUTH_JWT_MISSING" - RejectCodeJWTExpired = "AUTH_JWT_EXPIRED" - RejectCodeJWTInvalid = "AUTH_JWT_INVALID" RejectCodeAuthForbidden = "AUTH_FORBIDDEN" - RejectCodeAuthConfig = "AUTH_CONFIG" RejectCodeSessionError = "SESSION_ERROR" RejectCodeCapturerError = "CAPTURER_ERROR" RejectCodeUnsupportedOS = "UNSUPPORTED" @@ -56,6 +53,21 @@ const EnvVNCDisableDownscale = "NB_VNC_DISABLE_DOWNSCALE" // enough to coalesce bursty multi-session requests. 16 ms ~= 60 fps. const freshWindow = 16 * time.Millisecond +// maxConcurrentVNCConns caps in-flight VNC connections. Each accepted +// connection consumes a handler goroutine, a tracking entry, and (after +// handshake) capturer/encoder resources, so an unauthenticated peer that +// dials in a tight loop could otherwise grow memory without bound. The +// limit covers the entire accept→handshake→session window; a slot is +// released only when the handler returns. +const maxConcurrentVNCConns = 64 + +// maxFramebufferDim caps the screen dimensions accepted from a capturer. +// RFB serialises width/height as u16, and the encoder allocates per-frame +// buffers proportional to width*height*4. 8192 keeps width*height*4 well +// under 2^31 so int math doesn't overflow on 32-bit builds, and is large +// enough to cover real-world multi-monitor desktops. +const maxFramebufferDim = 8192 + // ScreenCapturer grabs desktop frames for the VNC server. type ScreenCapturer interface { // Width returns the current screen width in pixels. @@ -120,26 +132,22 @@ type InputInjector interface { TypeText(text string) } -// JWTConfig holds JWT validation configuration for VNC auth. -type JWTConfig struct { - Issuer string - KeysLocation string - MaxTokenAge int64 - Audiences []string -} - // connectionHeader is sent by the client before the RFB handshake to specify // the VNC session mode and authenticate. type connectionHeader struct { mode byte username string - jwt string + // clientStatic is the client's static X25519 public key learned from + // the Noise handshake. Populated when identityVerified is true. + clientStatic []byte // sessionID is the Windows session ID; 0 selects the console session. sessionID uint32 // width and height request the virtual display geometry for session mode. // Zero means use the default. width uint16 height uint16 + // identityVerified is true when the Noise_IK handshake completed. + identityVerified bool } // Server is the embedded VNC server that listens on the WireGuard interface. @@ -170,13 +178,16 @@ type Server struct { ctx context.Context cancel context.CancelFunc vmgr virtualSessionManager - jwtConfig *JWTConfig - jwtValidator *nbjwt.Validator - jwtExtractor *nbjwt.ClaimsExtractor - authorizer *sshauth.Authorizer - netstackNet *netstack.Net + authorizer *sshauth.Authorizer + netstackNet *netstack.Net // agentToken holds the raw token bytes for agent-mode auth. agentToken []byte + // identityKey is the daemon's static X25519 private key used in the + // Noise_IK handshake. Nil disables the handshake. + identityKey []byte + // identityPublic is the matching X25519 public key, derived once at + // construction to avoid recomputing per handshake. + identityPublic []byte sessionsMu sync.Mutex sessionSeq uint64 @@ -188,6 +199,17 @@ type Server struct { // closeActiveSessions iterates this set so Stop() can interrupt // handshaking peers, not just post-handshake sessions. acceptedConns map[net.Conn]struct{} + // connAuth holds the verified Noise_IK identity tied to each accepted + // connection so a later UpdateVNCAuth call can revoke live sessions + // whose authorization no longer holds. Populated by registerConnAuth + // once authenticateSession succeeds; absent entries (e.g. disableAuth + // or pre-handshake conns) are skipped at revocation time. + connAuth map[net.Conn]connAuthInfo + + // connSem caps concurrent accepted connections (handshake + session). + // Buffered with maxConcurrentVNCConns slots; accept loops try-acquire + // before spawning a handler and release on handler return. + connSem chan struct{} // sessionRecorder, when non-nil, receives a SessionTick periodically // during each VNC session and on session close. The engine wires @@ -195,12 +217,24 @@ type Server struct { sessionRecorder func(SessionTick) } +// connAuthInfo captures the Noise_IK-verified identity bound to a live +// connection so policy updates can re-check it and close sessions whose +// authorization was revoked. clientStatic is empty when auth was disabled +// for this connection, which signals that revocation does not apply. +type connAuthInfo struct { + clientStatic []byte + mode byte + username string +} + // ActiveSessionInfo describes a currently connected VNC client. type ActiveSessionInfo struct { RemoteAddress string Mode string Username string - JWTUsername string + // UserID is the authenticated session identity (hashed user ID from + // the Noise_IK static-key registration), empty when auth is disabled. + UserID string } // vncSession provides capturer and injector for a virtual display session. @@ -220,19 +254,31 @@ type virtualSessionManager interface { StopAll() } -// New creates a VNC server with the given screen capturer and input injector. -// Authentication uses a JWT supplied by the client in the connection -// header; the protocol-level VNC password scheme is not supported. -func New(capturer ScreenCapturer, injector InputInjector) *Server { - return &Server{ +// New creates a VNC server. identityKey is the 32-byte X25519 private +// key used by the daemon in the Noise_IK handshake; nil disables auth. +// The protocol-level VNC password scheme is not supported. +func New(capturer ScreenCapturer, injector InputInjector, identityKey []byte) *Server { + s := &Server{ capturer: capturer, injector: injector, + identityKey: identityKey, authorizer: sshauth.NewAuthorizer(), log: log.WithField("component", "vnc-server"), sessions: make(map[uint64]ActiveSessionInfo), sessionConns: make(map[uint64]net.Conn), acceptedConns: make(map[net.Conn]struct{}), + connAuth: make(map[net.Conn]connAuthInfo), + connSem: make(chan struct{}, maxConcurrentVNCConns), + } + if len(identityKey) == 32 { + pub, err := curve25519.X25519(identityKey, curve25519.Basepoint) + if err == nil { + s.identityPublic = pub + } else { + s.log.Warnf("derive identity public key: %v", err) + } } + return s } // ActiveSessions returns a snapshot of currently connected VNC clients. @@ -292,7 +338,73 @@ func (s *Server) trackConn(c net.Conn) { func (s *Server) untrackConn(c net.Conn) { s.sessionsMu.Lock() delete(s.acceptedConns, c) + delete(s.connAuth, c) + s.sessionsMu.Unlock() +} + +// registerConnAuth records the verified Noise_IK identity for a live +// connection so UpdateVNCAuth can later revoke it if policy changes. +// No-op when auth is disabled (e.g. agent-mode loopback connections). +func (s *Server) registerConnAuth(c net.Conn, header *connectionHeader) { + if s.disableAuth || header == nil || len(header.clientStatic) != 32 { + return + } + s.sessionsMu.Lock() + s.connAuth[c] = connAuthInfo{ + clientStatic: append([]byte(nil), header.clientStatic...), + mode: header.mode, + username: header.username, + } + s.sessionsMu.Unlock() +} + +// tryAcquireConnSlot returns true when a connection slot was successfully +// reserved. Releases must pair with releaseConnSlot. Returns false when +// the cap is already saturated; callers must close the connection. +func (s *Server) tryAcquireConnSlot() bool { + select { + case s.connSem <- struct{}{}: + return true + default: + return false + } +} + +func (s *Server) releaseConnSlot() { + select { + case <-s.connSem: + default: + } +} + +// revokeUnauthorizedSessions closes every live connection whose Noise- +// verified identity no longer authenticates under the current authorizer +// configuration. Called by UpdateVNCAuth after the new policy is applied. +func (s *Server) revokeUnauthorizedSessions() { + if s.disableAuth { + return + } + s.sessionsMu.Lock() + victims := make([]net.Conn, 0) + for c, info := range s.connAuth { + if len(info.clientStatic) != 32 { + continue + } + hdr := &connectionHeader{ + identityVerified: true, + clientStatic: info.clientStatic, + mode: info.mode, + username: info.username, + } + if _, err := s.authenticateSession(hdr); err != nil { + victims = append(victims, c) + s.log.Infof("revoking VNC session from %s: %v", c.RemoteAddr(), err) + } + } s.sessionsMu.Unlock() + for _, c := range victims { + _ = c.Close() + } } // SetServiceMode enables proxy-to-agent mode for Windows service operation. @@ -308,16 +420,6 @@ func (s *Server) SetSessionRecorder(recorder func(SessionTick)) { s.sessionRecorder = recorder } -// SetJWTConfig configures JWT authentication for VNC connections. -// Pass nil to disable JWT (public mode). -func (s *Server) SetJWTConfig(config *JWTConfig) { - s.mu.Lock() - defer s.mu.Unlock() - s.jwtConfig = config - s.jwtValidator = nil - s.jwtExtractor = nil -} - // SetDisableAuth disables authentication entirely. func (s *Server) SetDisableAuth(disable bool) { s.disableAuth = disable @@ -346,13 +448,14 @@ func (s *Server) SetNetstackNet(n *netstack.Net) { s.netstackNet = n } -// UpdateVNCAuth updates the fine-grained authorization configuration. +// UpdateVNCAuth updates the fine-grained authorization configuration and +// closes any live session whose identity no longer authenticates under +// the new policy. Revocation is event-driven: there is no periodic +// re-check, so a session stays open until either the next UpdateVNCAuth +// call or normal disconnect. func (s *Server) UpdateVNCAuth(config *sshauth.Config) { - s.mu.Lock() - defer s.mu.Unlock() - s.jwtValidator = nil - s.jwtExtractor = nil s.authorizer.Update(config) + s.revokeUnauthorizedSessions() } // Start begins listening for VNC connections on the given address. @@ -463,9 +566,15 @@ func (s *Server) acceptLoop() { continue } + if !s.tryAcquireConnSlot() { + s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns) + _ = conn.Close() + continue + } enableTCPKeepAlive(conn, s.log) s.trackConn(conn) go func(c net.Conn) { + defer s.releaseConnSlot() defer s.untrackConn(c) s.handleConnection(c) }(conn) @@ -565,16 +674,17 @@ func (s *Server) handleConnection(conn net.Conn) { if !s.verifyAgentToken(conn, connLog) { return } - header, err := readConnectionHeader(conn) + header, err := s.readConnectionHeader(conn) if err != nil { connLog.Warnf("read connection header: %v", err) conn.Close() return } - connLog, jwtUserID, ok := s.authorizeJWT(conn, header, connLog) + connLog, sessionUserID, ok := s.authorizeSession(conn, header, connLog) if !ok { return } + s.registerConnAuth(conn, header) capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog) if !ok { @@ -586,7 +696,7 @@ func (s *Server) handleConnection(conn net.Conn) { RemoteAddress: conn.RemoteAddr().String(), Mode: modeString(header.mode), Username: header.username, - JWTUsername: jwtUserID, + UserID: sessionUserID, }, conn) defer s.removeSession(sessionID) @@ -596,13 +706,20 @@ func (s *Server) handleConnection(conn net.Conn) { return } + w, h := capturer.Width(), capturer.Height() + if w <= 0 || h <= 0 || w > maxFramebufferDim || h > maxFramebufferDim { + rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("framebuffer dimensions out of range: %dx%d", w, h))) + connLog.Warnf("rejecting session: framebuffer %dx%d outside [1, %d]", w, h, maxFramebufferDim) + return + } + conn = newMetricsConn(conn, s.sessionRecorder) sess := &session{ conn: conn, capturer: capturer, injector: injector, - serverW: capturer.Width(), - serverH: capturer.Height(), + serverW: w, + serverH: h, log: connLog, } sess.serve() @@ -615,25 +732,6 @@ func codeMessage(code, msg string) string { return code + ": " + msg } -// jwtErrorCode maps a JWT auth error to a stable reject code. -func jwtErrorCode(err error) string { - if err == nil { - return RejectCodeJWTInvalid - } - if errors.Is(err, nbjwt.ErrTokenExpired) { - return RejectCodeJWTExpired - } - msg := err.Error() - switch { - case strings.Contains(msg, "JWT required but not provided"): - return RejectCodeJWTMissing - case strings.Contains(msg, "authorize") || strings.Contains(msg, "not authorized"): - return RejectCodeAuthForbidden - default: - return RejectCodeJWTInvalid - } -} - // rejectConnection sends a minimal RFB handshake with a security failure // reason, so VNC clients display the error message instead of a generic // "unexpected disconnect." @@ -658,105 +756,57 @@ func rejectConnection(conn net.Conn, reason string) { _, _ = conn.Write(buf) } -const defaultJWTMaxTokenAge = 10 * 60 // 10 minutes - -// authenticateJWT validates the JWT from the connection header and checks -// authorization. For attach mode, just checks membership in the authorized -// user list. For session mode, additionally validates the OS user mapping. -func (s *Server) authenticateJWT(header *connectionHeader) (string, error) { - if header.jwt == "" { - return "", fmt.Errorf("JWT required but not provided") +// authenticateSession resolves the Noise-verified client static public +// key to a hashed user identity via the authorizer, and checks OS-user +// mapping for session mode. Returns the hashed user identity on success. +func (s *Server) authenticateSession(header *connectionHeader) (string, error) { + if !header.identityVerified { + return "", fmt.Errorf("identity proof missing") } - - s.mu.Lock() - if err := s.ensureJWTValidator(); err != nil { - s.mu.Unlock() - return "", fmt.Errorf("initialize JWT validator: %w", err) + if len(header.clientStatic) != 32 { + return "", fmt.Errorf("client static key missing") } - validator := s.jwtValidator - extractor := s.jwtExtractor - s.mu.Unlock() - token, err := validator.ValidateAndParse(context.Background(), header.jwt) + userIDHash, err := s.authorizer.LookupSessionKey(header.clientStatic) if err != nil { - return "", fmt.Errorf("validate JWT: %w", err) + return "", fmt.Errorf("lookup session pubkey: %w", err) } - if err := s.checkTokenAge(token); err != nil { - return "", err - } - - userAuth, err := extractor.ToUserAuth(token) - if err != nil { - return "", fmt.Errorf("extract user from JWT: %w", err) - } - if userAuth.UserId == "" { - return "", fmt.Errorf("JWT has no user ID") + osUser := "*" + if header.mode == ModeSession { + osUser = header.username } - - switch header.mode { - case ModeSession: - // Session mode: check user + OS username mapping. - if _, err := s.authorizer.Authorize(userAuth.UserId, header.username); err != nil { - return "", fmt.Errorf("authorize session for %s: %w", header.username, err) - } - default: - // Attach mode: just check user is in the authorized list (wildcard OS user). - if _, err := s.authorizer.Authorize(userAuth.UserId, "*"); err != nil { - return "", fmt.Errorf("user not authorized for VNC: %w", err) - } + if _, err := s.authorizer.AuthorizeOSUserBySessionKey(userIDHash, osUser); err != nil { + return "", fmt.Errorf("authorize OS user %q: %w", osUser, err) } - - return userAuth.UserId, nil + return userIDHash.String(), nil } -// ensureJWTValidator lazily initializes the JWT validator. Must be called with mu held. -func (s *Server) ensureJWTValidator() error { - if s.jwtValidator != nil && s.jwtExtractor != nil { - return nil - } - if s.jwtConfig == nil { - return fmt.Errorf("no JWT config") - } - - // Enable IdP key refresh so JWKS rotations don't latch the validator - // off until daemon restart. - s.jwtValidator = nbjwt.NewValidator( - s.jwtConfig.Issuer, - s.jwtConfig.Audiences, - s.jwtConfig.KeysLocation, - true, - ) - - var opts []nbjwt.ClaimsExtractorOption - if len(s.jwtConfig.Audiences) > 0 { - opts = append(opts, nbjwt.WithAudience(s.jwtConfig.Audiences[0])) - } - if claim := s.authorizer.GetUserIDClaim(); claim != "" { - opts = append(opts, nbjwt.WithUserIDClaim(claim)) - } - s.jwtExtractor = nbjwt.NewClaimsExtractor(opts...) +var vncIdentityMagic = []byte("NBV3") - return nil -} +// Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads). +// msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes +// msg2 = e(32) + payload_AEAD(0+16) = 48 bytes +const ( + noiseInitiatorMsgLen = 96 + noiseResponderMsgLen = 48 +) -func (s *Server) checkTokenAge(token *gojwt.Token) error { - maxAge := defaultJWTMaxTokenAge - if s.jwtConfig != nil && s.jwtConfig.MaxTokenAge > 0 { - maxAge = int(s.jwtConfig.MaxTokenAge) - } - return nbjwt.CheckTokenAge(token, time.Duration(maxAge)*time.Second) -} +// vncNoiseSuite pins the cipher suite for the VNC handshake. Changing +// it requires bumping vncIdentityMagic so old clients fail closed. +var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) -// readConnectionHeader reads the NetBird VNC session header from the connection. -// Format: [mode: 1 byte] [username_len: 2 bytes BE] [username: N bytes] +// readConnectionHeader reads the NetBird VNC session header. Format: // -// [jwt_len: 2 bytes BE] [jwt: N bytes] +// [mode: 1] [username_len: 2 BE] [username: N] +// [opt magic "NBV3": 4] [noise_msg1: 96] +// (server writes [noise_msg2: 48] here when the magic is present) +// [session_id: 4 BE] [width: 2 BE] [height: 2 BE] // -// Uses a short timeout: our WASM proxy sends the header immediately after -// connecting. Standard VNC clients don't send anything first (server speaks -// first in RFB), so they time out and get the default attach mode. -func readConnectionHeader(conn net.Conn) (*connectionHeader, error) { +// Standard VNC clients don't speak first, so they time out on the first +// read and fall through to attach mode (which auth still rejects when +// no Noise handshake completed). +func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) { if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { return nil, fmt.Errorf("set deadline: %w", err) } @@ -764,11 +814,9 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) { var hdr [3]byte if _, err := io.ReadFull(conn, hdr[:]); err != nil { - // Timeout or error: assume no header, use attach mode. return &connectionHeader{mode: ModeAttach}, nil } - // Restore a longer deadline for reading variable-length fields. if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { return nil, fmt.Errorf("set deadline: %w", err) } @@ -788,48 +836,93 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) { username = string(buf) } - // Read JWT token length and data. - var jwtLenBuf [2]byte - var jwtToken string - if _, err := io.ReadFull(conn, jwtLenBuf[:]); err == nil { - jwtLen := binary.BigEndian.Uint16(jwtLenBuf[:]) - if jwtLen >= 8192 { - return nil, fmt.Errorf("jwt too long: %d (max 8191)", jwtLen) - } - if jwtLen > 0 { - buf := make([]byte, jwtLen) - if _, err := io.ReadFull(conn, buf); err != nil { - return nil, fmt.Errorf("read JWT: %w", err) - } - jwtToken = string(buf) - } + br := bufio.NewReader(conn) + clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br) + if err != nil { + return nil, err } - // Read optional Windows session ID (4 bytes BE). Missing = 0 (console/auto). var sessionID uint32 var sidBuf [4]byte - if _, err := io.ReadFull(conn, sidBuf[:]); err == nil { + if _, err := io.ReadFull(br, sidBuf[:]); err == nil { sessionID = binary.BigEndian.Uint32(sidBuf[:]) } - // Read optional requested viewport size (2x uint16 BE). Missing = 0 (default). var width, height uint16 var geomBuf [4]byte - if _, err := io.ReadFull(conn, geomBuf[:]); err == nil { + if _, err := io.ReadFull(br, geomBuf[:]); err == nil { width = binary.BigEndian.Uint16(geomBuf[0:2]) height = binary.BigEndian.Uint16(geomBuf[2:4]) } return &connectionHeader{ - mode: mode, - username: username, - jwt: jwtToken, - sessionID: sessionID, - width: width, - height: height, + mode: mode, + username: username, + clientStatic: clientStatic, + sessionID: sessionID, + width: width, + height: height, + identityVerified: identityVerified, }, nil } +// maybeRunNoiseHandshake performs the responder side of a Noise_IK +// handshake when the client sends the v3 magic. Returns the client static +// public key learned from the handshake. Any handshake failure is fatal +// (fail closed). +func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte, bool, error) { + peek, err := br.Peek(len(vncIdentityMagic)) + if err != nil || !bytes.Equal(peek, vncIdentityMagic) { + return nil, false, nil + } + if _, err := br.Discard(len(vncIdentityMagic)); err != nil { + return nil, false, fmt.Errorf("discard identity magic: %w", err) + } + + msg1 := make([]byte, noiseInitiatorMsgLen) + if _, err := io.ReadFull(br, msg1); err != nil { + return nil, false, fmt.Errorf("read noise msg1: %w", err) + } + + // Agents on loopback authenticate via the agent token, not this + // handshake. Consume the replayed bytes and skip the response. + if s.disableAuth { + return nil, true, nil + } + + if len(s.identityKey) != 32 || len(s.identityPublic) != 32 { + return nil, false, errors.New("identity key not configured") + } + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: false, + StaticKeypair: noise.DHKey{Private: s.identityKey, Public: s.identityPublic}, + }) + if err != nil { + return nil, false, fmt.Errorf("noise responder init: %w", err) + } + if _, _, _, err := state.ReadMessage(nil, msg1); err != nil { + return nil, false, fmt.Errorf("noise read msg1: %w", err) + } + msg2, _, _, err := state.WriteMessage(nil, nil) + if err != nil { + return nil, false, fmt.Errorf("noise write msg2: %w", err) + } + if len(msg2) != noiseResponderMsgLen { + return nil, false, fmt.Errorf("noise responder produced %d bytes, expected %d", len(msg2), noiseResponderMsgLen) + } + if _, err := conn.Write(msg2); err != nil { + return nil, false, fmt.Errorf("write noise msg2: %w", err) + } + + clientStatic := state.PeerStatic() + if len(clientStatic) != 32 { + return nil, false, errors.New("noise peer static missing") + } + return clientStatic, true, nil +} + // verifyAgentToken validates the agent token prefix when configured. Returns // false when the token is invalid or unreadable; the connection is closed. func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool { @@ -865,25 +958,34 @@ func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool { return true } -// authorizeJWT performs JWT validation when auth is enabled. Returns the -// enriched log entry, jwt user ID (empty when auth disabled), and ok=false -// if the connection was rejected. -func (s *Server) authorizeJWT(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) { +// authorizeSession runs the Noise_IK handshake when auth is enabled. +// Returns the enriched log entry, user identity hash (empty when auth +// disabled), and ok=false if the connection was rejected. +func (s *Server) authorizeSession(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) { if s.disableAuth { return connLog, "", true } - if s.jwtConfig == nil { - rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) - connLog.Warn("auth rejected: no identity provider configured") - return connLog, "", false - } - jwtUserID, err := s.authenticateJWT(header) + userID, err := s.authenticateSession(header) if err != nil { - rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) + rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) connLog.Warnf("auth rejected: %v", err) return connLog, "", false } - return connLog.WithField("jwt_user", jwtUserID), jwtUserID, true + return connLog.WithFields(log.Fields{ + "session_user": userID, + "session_key": sessionKeyFingerprint(header.clientStatic), + }), userID, true +} + +// sessionKeyFingerprint returns a short hex fingerprint of a client +// static key for log correlation. Distinct VNC sessions of the same +// user end up with distinct fingerprints because each session mints a +// fresh keypair, so this lets an operator tell parallel sessions apart. +func sessionKeyFingerprint(clientStatic []byte) string { + if len(clientStatic) < 4 { + return "" + } + return hex.EncodeToString(clientStatic[:4]) } // acquireSessionResources returns the capturer/injector to use for this diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 8682cc38b5b..af77fe63dbd 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -47,10 +47,16 @@ func (s *Server) serviceAcceptLoop() { continue } + if !s.tryAcquireConnSlot() { + s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns) + _ = conn.Close() + continue + } enableTCPKeepAlive(conn, s.log) conn = newMetricsConn(conn, s.sessionRecorder) s.trackConn(conn) go func(c net.Conn) { + defer s.releaseConnSlot() defer s.untrackConn(c) s.handleServiceConnectionDarwin(c, mgr) }(conn) @@ -69,7 +75,7 @@ func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentMa tee := io.TeeReader(conn, &headerBuf) teeConn := &darwinPrefixConn{Reader: tee, Conn: conn} - header, err := readConnectionHeader(teeConn) + header, err := s.readConnectionHeader(teeConn) if err != nil { connLog.Debugf("read connection header: %v", err) conn.Close() @@ -77,17 +83,13 @@ func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentMa } if !s.disableAuth { - if s.jwtConfig == nil { - rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) - connLog.Warn("auth rejected: no identity provider configured") - return - } - if _, err := s.authenticateJWT(header); err != nil { - rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) + if _, err := s.authenticateSession(header); err != nil { + rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) connLog.Warnf("auth rejected: %v", err) return } } + s.registerConnAuth(conn, header) token, err := mgr.ensure(s.ctx) if err != nil { diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index db8dbce5336..ea37673d192 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -9,7 +9,6 @@ import ( "io" "net" "net/netip" - "strings" "testing" "time" @@ -26,14 +25,11 @@ func (t *testCapturer) Capture() (*image.RGBA, error) { return image.NewRGBA(image.Rect(0, 0, 100, 100)), nil } -func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net.Addr, *Server) { +func startTestServer(t *testing.T, disableAuth bool) (net.Addr, *Server) { t.Helper() - srv := New(&testCapturer{}, &StubInputInjector{}) + srv := New(&testCapturer{}, &StubInputInjector{}, nil) srv.SetDisableAuth(disableAuth) - if jwtConfig != nil { - srv.SetJWTConfig(jwtConfig) - } addr := netip.MustParseAddrPort("127.0.0.1:0") network := netip.MustParsePrefix("127.0.0.0/8") @@ -45,30 +41,28 @@ func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net. return srv.listener.Addr(), srv } -func TestAuthEnabled_NoJWTConfig_RejectsConnection(t *testing.T) { - addr, _ := startTestServer(t, false, nil) +func TestAuthEnabled_NoSessionAuth_RejectsConnection(t *testing.T) { + addr, _ := startTestServer(t, false) conn, err := net.Dial("tcp", addr.String()) require.NoError(t, err) defer conn.Close() - // Send session header: attach mode, no username, no JWT. - header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 + // Header with no Noise handshake. Auth-required servers must reject + // because no client static was authenticated. + header := make([]byte, 11) // mode + usernameLen + sessionID + w + h header[0] = ModeAttach _, err = conn.Write(header) require.NoError(t, err) - // Server should send RFB version then security failure. var version [12]byte _, err = io.ReadFull(conn, version[:]) require.NoError(t, err) assert.Equal(t, "RFB 003.008\n", string(version[:])) - // Write client version to proceed through handshake. _, err = conn.Write(version[:]) require.NoError(t, err) - // Read security types: 0 means failure, followed by reason. var numTypes [1]byte _, err = io.ReadFull(conn, numTypes[:]) require.NoError(t, err) @@ -81,18 +75,17 @@ func TestAuthEnabled_NoJWTConfig_RejectsConnection(t *testing.T) { reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:])) _, err = io.ReadFull(conn, reason) require.NoError(t, err) - assert.Contains(t, string(reason), "identity provider", "rejection reason should mention missing IdP config") + assert.Contains(t, string(reason), "identity proof missing", "rejection reason should mention missing identity proof") } func TestAuthDisabled_AllowsConnection(t *testing.T) { - addr, _ := startTestServer(t, true, nil) + addr, _ := startTestServer(t, true) conn, err := net.Dial("tcp", addr.String()) require.NoError(t, err) defer conn.Close() - // Send session header: attach mode, no username, no JWT. - header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 + header := make([]byte, 11) // mode + usernameLen + sessionID + w + h header[0] = ModeAttach _, err = conn.Write(header) require.NoError(t, err) @@ -114,70 +107,12 @@ func TestAuthDisabled_AllowsConnection(t *testing.T) { assert.NotEqual(t, byte(0), numTypes[0], "should have at least one security type (auth disabled)") } -// TestAuthEnabled_InvalidJWT_RejectedBeforeRFB confirms the VNC server itself -// (not just the JWT library) wires authentication into handleConnection. A -// well-formed JWT-shaped token must hit the server's validation path and be -// rejected with an AUTH_JWT_* reason, never reaching the RFB handshake. -func TestAuthEnabled_InvalidJWT_RejectedBeforeRFB(t *testing.T) { - addr, _ := startTestServer(t, false, &JWTConfig{ - Issuer: "https://example.invalid", - KeysLocation: "https://example.invalid/.well-known/jwks.json", - Audiences: []string{"test"}, - }) - - // Three-segment "JWT" with bogus base64. The server's authenticateJWT path - // must catch this regardless of the IdP being unreachable. - bogusJWT := "abc.def.ghi" - header := make([]byte, 3+2+len(bogusJWT)+4+4) - header[0] = ModeAttach - binary.BigEndian.PutUint16(header[1:3], 0) // username len - binary.BigEndian.PutUint16(header[3:5], uint16(len(bogusJWT))) - copy(header[5:5+len(bogusJWT)], bogusJWT) - - conn, err := net.Dial("tcp", addr.String()) - require.NoError(t, err) - defer conn.Close() - require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) - - _, err = conn.Write(header) - require.NoError(t, err) - - var version [12]byte - _, err = io.ReadFull(conn, version[:]) - require.NoError(t, err) - _, err = conn.Write(version[:]) - require.NoError(t, err) - - var numTypes [1]byte - _, err = io.ReadFull(conn, numTypes[:]) - require.NoError(t, err) - require.Equal(t, byte(0), numTypes[0], "must fail security negotiation") - - var reasonLen [4]byte - _, err = io.ReadFull(conn, reasonLen[:]) - require.NoError(t, err) - reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:])) - _, err = io.ReadFull(conn, reason) - require.NoError(t, err) - // The reason must carry one of the server's AUTH_JWT_* codes, proving - // the rejection came from authenticateJWT in handleConnection. - r := string(reason) - hasJWTReject := false - for _, code := range []string{RejectCodeJWTInvalid, RejectCodeJWTExpired, RejectCodeAuthForbidden} { - if strings.Contains(r, code) { - hasJWTReject = true - break - } - } - assert.True(t, hasJWTReject, "reason %q must include an AUTH_JWT_* code", r) -} - // TestAuth_NoUnauthBytesPastHeader proves the server does not send any RFB // content to a connection that fails source validation. Specifically, the // server must close immediately and the client must see EOF before any RFB // version greeting is written. func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}) + srv := New(&testCapturer{}, &StubInputInjector{}, nil) srv.SetDisableAuth(true) addr := netip.MustParseAddrPort("127.0.0.1:0") // Tight overlay that excludes 127.0.0.0/8 and a non-loopback local IP, so @@ -198,37 +133,6 @@ func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { require.Error(t, err, "non-overlay client must see EOF, not an RFB greeting") } -func TestAuthEnabled_EmptyJWT_Rejected(t *testing.T) { - // Auth enabled with a (bogus) JWT config: connections without JWT should be rejected. - addr, _ := startTestServer(t, false, &JWTConfig{ - Issuer: "https://example.com", - KeysLocation: "https://example.com/.well-known/jwks.json", - Audiences: []string{"test"}, - }) - - conn, err := net.Dial("tcp", addr.String()) - require.NoError(t, err) - defer conn.Close() - - // Send session header with empty JWT. - header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 - header[0] = ModeAttach - _, err = conn.Write(header) - require.NoError(t, err) - - var version [12]byte - _, err = io.ReadFull(conn, version[:]) - require.NoError(t, err) - - _, err = conn.Write(version[:]) - require.NoError(t, err) - - var numTypes [1]byte - _, err = io.ReadFull(conn, numTypes[:]) - require.NoError(t, err) - assert.Equal(t, byte(0), numTypes[0], "should reject with 0 security types") -} - func TestIsAllowedSource(t *testing.T) { tests := []struct { name string @@ -289,7 +193,7 @@ func TestIsAllowedSource(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}) + srv := New(&testCapturer{}, &StubInputInjector{}, nil) srv.localAddr = tc.localAddr srv.network = tc.network assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote)) @@ -298,7 +202,7 @@ func TestIsAllowedSource(t *testing.T) { } func TestStart_InvalidNetworkRejected(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}) + srv := New(&testCapturer{}, &StubInputInjector{}, nil) addr := netip.MustParseAddrPort("127.0.0.1:0") err := srv.Start(t.Context(), addr, netip.Prefix{}) require.Error(t, err, "Start must refuse an invalid overlay prefix") @@ -306,7 +210,7 @@ func TestStart_InvalidNetworkRejected(t *testing.T) { } func TestAgentToken_MismatchClosesConnection(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}) + srv := New(&testCapturer{}, &StubInputInjector{}, nil) srv.SetDisableAuth(true) srv.SetAgentToken("deadbeefcafebabe") @@ -334,7 +238,7 @@ func TestAgentToken_MismatchClosesConnection(t *testing.T) { } func TestAgentToken_MatchAllowsHandshake(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}) + srv := New(&testCapturer{}, &StubInputInjector{}, nil) srv.SetDisableAuth(true) const tokenHex = "deadbeefcafebabe" srv.SetAgentToken(tokenHex) @@ -356,7 +260,7 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) { require.NoError(t, err) // Send session header so handleConnection can proceed past readConnectionHeader. - header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 + header := make([]byte, 11) // ModeAttach + usernameLen=0 + sessionID=0 + width=0 + height=0 header[0] = ModeAttach _, err = conn.Write(header) require.NoError(t, err) @@ -371,7 +275,7 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) { func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) { // Default platformSessionManager() on non-Linux returns nil, so ModeSession // must be rejected with the UNSUPPORTED reason rather than crashing. - srv := New(&testCapturer{}, &StubInputInjector{}) + srv := New(&testCapturer{}, &StubInputInjector{}, nil) srv.SetDisableAuth(true) addr := netip.MustParseAddrPort("127.0.0.1:0") @@ -387,7 +291,7 @@ func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) { defer conn.Close() require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) - // ModeSession with no username/JWT, so we exit on the vmgr==nil branch + // ModeSession with no username, so we exit on the vmgr==nil branch // before username validation runs. header := []byte{ModeSession, 0, 0, 0, 0} _, err = conn.Write(header) diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index d47d13839af..70cbdf16e75 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -233,8 +233,9 @@ func (s *Server) platformInit() { startSASListener(s.ctx) } -// serviceAcceptLoop runs in Session 0. It validates source IP and -// authenticates via JWT before proxying connections to the user-session agent. +// serviceAcceptLoop runs in Session 0. It validates the source IP and +// hands accepted connections to handleServiceConnection, which runs the +// Noise_IK handshake before proxying to the user-session agent. func (s *Server) serviceAcceptLoop() { sm := newSessionManager(agentPort) @@ -255,18 +256,25 @@ func (s *Server) serviceAcceptLoop() { continue } + if !s.tryAcquireConnSlot() { + s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns) + _ = conn.Close() + continue + } enableTCPKeepAlive(conn, s.log) conn = newMetricsConn(conn, s.sessionRecorder) s.trackConn(conn) go func(c net.Conn) { + defer s.releaseConnSlot() defer s.untrackConn(c) s.handleServiceConnection(c, sm) }(conn) } } -// handleServiceConnection validates the source IP and JWT, then proxies -// the connection (with header bytes replayed) to the agent. +// handleServiceConnection runs the connection-header handshake (including +// Noise_IK), then proxies the connection (with header bytes replayed) to +// the agent listening on loopback. func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) { connLog := s.log.WithField("remote", conn.RemoteAddr().String()) @@ -279,7 +287,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) { tee := io.TeeReader(conn, &headerBuf) teeConn := &prefixConn{Reader: tee, Conn: conn} - header, err := readConnectionHeader(teeConn) + header, err := s.readConnectionHeader(teeConn) if err != nil { connLog.Debugf("read connection header: %v", err) conn.Close() @@ -287,17 +295,13 @@ func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) { } if !s.disableAuth { - if s.jwtConfig == nil { - rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) - connLog.Warn("auth rejected: no identity provider configured") - return - } - if _, err := s.authenticateJWT(header); err != nil { - rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) + if _, err := s.authenticateSession(header); err != nil { + rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) connLog.Warnf("auth rejected: %v", err) return } } + s.registerConnAuth(conn, header) // Replay buffered header bytes + remaining stream to the agent. replayConn := &prefixConn{ diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 495f32c6458..50db0265814 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -222,9 +222,9 @@ func (s *session) handshake() error { } // sendSecurityTypes advertises only secNone. Authentication and access -// control happen in the NetBird connection header (JWT, mode, username) -// that precedes the RFB handshake, not via the protocol-level password -// scheme. +// control happen in the NetBird connection header (Noise_IK handshake, +// mode, username) that precedes the RFB handshake; the protocol-level +// password scheme is not supported. func (s *session) sendSecurityTypes() error { _, err := s.conn.Write([]byte{1, secNone}) return err diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index a8470c655c7..9c8993fb6c3 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -225,6 +225,10 @@ func (s *session) handleResize() error { if w <= 0 || h <= 0 { return nil } + if w > maxFramebufferDim || h > maxFramebufferDim { + s.log.Warnf("ignoring resize: %dx%d exceeds cap %d", w, h, maxFramebufferDim) + return nil + } if w == s.serverW && h == s.serverH { return nil } diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index aed7d2b2a84..1ebae635a8e 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -4,6 +4,7 @@ package main import ( "context" + "encoding/base64" "fmt" "net" "strconv" @@ -39,6 +40,7 @@ const ( func main() { js.Global().Set("NetBirdClient", js.FuncOf(netBirdClientConstructor)) + js.Global().Set("netbirdGenerateVNCSessionKey", createGenerateVNCSessionKeyMethod()) select {} } @@ -388,13 +390,31 @@ func createRDPProxyMethod(client *netbird.Client) js.Func { }) } +// createGenerateVNCSessionKeyMethod returns a JS func that mints a fresh +// X25519 keypair, stashes the private half inside wasm under a random +// session id, and returns { publicKey, sessionId } to JS. The private +// key never leaves the wasm heap. +func createGenerateVNCSessionKeyMethod() js.Func { + return js.FuncOf(func(_ js.Value, _ []js.Value) any { + id, pub, err := vnc.NewSessionKey() + if err != nil { + return js.ValueOf(err.Error()) + } + out := js.Global().Get("Object").New() + out.Set("sessionId", id) + out.Set("publicKey", base64.StdEncoding.EncodeToString(pub)) + return out + }) +} + // createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging. -// JS signature: createVNCProxy(hostname, port, mode?, username?, jwt?, sessionID?, width?, height?) -// mode: "attach" (default) or "session" -// username: required when mode is "session" -// jwt: authentication token (from OIDC session) -// sessionID: Windows session ID (0 = console/auto) -// width/height: requested viewport size for session mode (0 = server default) +// JS signature: createVNCProxy(hostname, port, mode?, username?, keySessionID?, sessionID?, width?, height?, peerPublicKey?) +// mode: "attach" (default) or "session" +// username: required when mode is "session" +// keySessionID: handle for the wasm-resident session keypair minted by netbirdGenerateVNCSessionKey +// sessionID: Windows session ID (0 = console/auto) +// width/height: requested viewport size for session mode (0 = server default) +// peerPublicKey: base64 X25519 static pubkey of the destination peer (required for auth) func createVNCProxyMethod(client *netbird.Client) js.Func { return js.FuncOf(func(_ js.Value, args []js.Value) any { params, err := parseVNCProxyArgs(args) @@ -408,14 +428,15 @@ func createVNCProxyMethod(client *netbird.Client) js.Func { } proxy := vnc.NewVNCProxy(client) return proxy.CreateProxy(vnc.ProxyRequest{ - Hostname: params.hostname, - Port: params.port, - Mode: params.mode, - Username: params.username, - JWT: params.jwt, - SessionID: params.sessionID, - Width: params.width, - Height: params.height, + Hostname: params.hostname, + Port: params.port, + Mode: params.mode, + Username: params.username, + SessionID: params.sessionID, + Width: params.width, + Height: params.height, + PeerPublicKey: params.peerPublicKey, + KeySessionID: params.keySessionID, }) }) } @@ -425,11 +446,12 @@ type vncProxyParams struct { port string mode string username string - jwt string + keySessionID string sessionID uint32 width uint16 height uint16 - rejectViaPromise bool // true when the JS caller expects a rejected Promise instead of a plain string return + peerPublicKey string + rejectViaPromise bool } // parseVNCProxyArgs validates JS args for createVNCProxyMethod and returns @@ -480,7 +502,7 @@ func parseVNCProxyOptionalStrings(args []js.Value, p *vncProxyParams) error { p.username = args[3].String() } if len(args) > 4 && args[4].Type() == js.TypeString { - p.jwt = args[4].String() + p.keySessionID = args[4].String() } return nil } @@ -512,6 +534,9 @@ func parseVNCProxyOptionalNumbers(args []js.Value, p *vncProxyParams) error { } p.height = uint16(v) } + if len(args) > 8 && args[8].Type() == js.TypeString { + p.peerPublicKey = args[8].String() + } return nil } diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 84718383c53..e6ced7ca10d 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -4,6 +4,8 @@ package vnc import ( "context" + crand "crypto/rand" + "encoding/base64" "errors" "fmt" "io" @@ -13,9 +15,65 @@ import ( "syscall/js" "time" + "github.com/flynn/noise" log "github.com/sirupsen/logrus" ) +var cryptoRandRead = crand.Read + +// vncIdentityMagic mirrors the server side in client/vnc/server/server.go. +var vncIdentityMagic = []byte("NBV3") + +// Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads). +const ( + noiseInitiatorMsgLen = 96 + noiseResponderMsgLen = 48 +) + +var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) + +// sessionKeyStore retains per-session X25519 keypairs so the JS layer +// only sees an opaque session id + the public key; the private key never +// leaves wasm. +var sessionKeyStore = struct { + mu sync.Mutex + keys map[string]noise.DHKey +}{keys: map[string]noise.DHKey{}} + +// NewSessionKey mints an X25519 keypair, stores the private half under a +// fresh random session id, and returns (id, pubkey). +func NewSessionKey() (string, []byte, error) { + kp, err := noise.DH25519.GenerateKeypair(nil) + if err != nil { + return "", nil, fmt.Errorf("generate keypair: %w", err) + } + idBytes := make([]byte, 16) + if _, err := cryptoRandRead(idBytes); err != nil { + return "", nil, fmt.Errorf("session id randomness: %w", err) + } + id := base64.RawURLEncoding.EncodeToString(idBytes) + sessionKeyStore.mu.Lock() + sessionKeyStore.keys[id] = kp + sessionKeyStore.mu.Unlock() + return id, kp.Public, nil +} + +// lookupSessionKey returns the keypair for id, or false if unknown. +func lookupSessionKey(id string) (noise.DHKey, bool) { + sessionKeyStore.mu.Lock() + defer sessionKeyStore.mu.Unlock() + kp, ok := sessionKeyStore.keys[id] + return kp, ok +} + +// dropSessionKey removes the keypair for id. Called after the VNC +// connection closes (or after a connect attempt fails terminally). +func dropSessionKey(id string) { + sessionKeyStore.mu.Lock() + delete(sessionKeyStore.keys, id) + sessionKeyStore.mu.Unlock() +} + const ( vncProxyHost = "vnc.proxy.local" vncProxyScheme = "ws" @@ -37,10 +95,12 @@ const ( // VNCProxy bridges WebSocket connections from noVNC in the browser // to TCP VNC server connections through the NetBird tunnel. +type vncNBClient interface { + Dial(ctx context.Context, network, address string) (net.Conn, error) +} + type VNCProxy struct { - nbClient interface { - Dial(ctx context.Context, network, address string) (net.Conn, error) - } + nbClient vncNBClient activeConnections map[string]*vncConnection destinations map[string]vncDestination // pendingHandlers holds the js.Func for handleVNCWebSocket_ between @@ -52,13 +112,15 @@ type VNCProxy struct { } type vncDestination struct { - address string - mode byte - username string - jwt string - sessionID uint32 // Windows session ID (0 = auto/console) - width uint16 // Requested viewport width for session mode (0 = default) - height uint16 // Requested viewport height for session mode (0 = default) + address string + mode byte + username string + sessionPriv []byte + sessionPub []byte + sessionID uint32 + width uint16 + height uint16 + peerPubKey []byte } type vncConnection struct { @@ -78,9 +140,7 @@ type vncConnection struct { } // NewVNCProxy creates a new VNC proxy. -func NewVNCProxy(client interface { - Dial(ctx context.Context, network, address string) (net.Conn, error) -}) *VNCProxy { +func NewVNCProxy(client vncNBClient) *VNCProxy { return &VNCProxy{ nbClient: client, activeConnections: make(map[string]*vncConnection), @@ -94,10 +154,16 @@ type ProxyRequest struct { Port string Mode string Username string - JWT string SessionID uint32 Width uint16 Height uint16 + // PeerPublicKey is the destination peer's base64 X25519 public key, + // used as the responder static in the Noise_IK handshake. + PeerPublicKey string + // KeySessionID is the handle returned by generateVNCSessionKey. The + // matching private key is looked up inside wasm and never crosses + // the JS boundary. + KeySessionID string } // CreateProxy creates a new proxy endpoint for the given VNC destination. @@ -106,7 +172,7 @@ type ProxyRequest struct { // virtual display geometry for session mode; 0 means use the server default. // Returns a JS Promise that resolves to the WebSocket proxy URL. func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { - hostname, port, mode, username, jwt := req.Hostname, req.Port, req.Mode, req.Username, req.JWT + hostname, port, mode, username := req.Hostname, req.Port, req.Mode, req.Username sessionID, width, height := req.SessionID, req.Width, req.Height address := net.JoinHostPort(hostname, port) @@ -119,14 +185,51 @@ func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { address: address, mode: m, username: username, - jwt: jwt, sessionID: sessionID, width: width, height: height, } + if req.KeySessionID != "" { + kp, ok := lookupSessionKey(req.KeySessionID) + if !ok { + return rejectedPromise("unknown VNC session id") + } + // A session handle is single-use; drop it before the destination + // holds the private bytes so a leaked handle can't be replayed. + dropSessionKey(req.KeySessionID) + dest.sessionPriv = kp.Private + dest.sessionPub = kp.Public + pub, err := decodePeerPubKey(req.PeerPublicKey) + if err != nil { + return rejectedPromise(fmt.Sprintf("invalid peer public key: %v", err)) + } + dest.peerPubKey = pub + } return p.newProxyPromise(address, mode, username, dest) } +// decodePeerPubKey parses a base64-encoded 32-byte X25519 public key. +func decodePeerPubKey(b64 string) ([]byte, error) { + if b64 == "" { + return nil, errors.New("peer public key missing") + } + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("base64 decode: %w", err) + } + if len(raw) != 32 { + return nil, fmt.Errorf("expected 32 bytes, got %d", len(raw)) + } + return raw, nil +} + +// rejectedPromise returns a resolved Promise carrying msg as an error +// string, mirroring how CreateProxy reports earlier validation failures. +func rejectedPromise(msg string) js.Value { + promise := js.Global().Get("Promise") + return promise.Call("resolve", js.ValueOf(msg)) +} + // newProxyPromise wraps the JS Promise creation + executor lifecycle so // CreateProxy stays a thin parameter-bundling entrypoint. func (p *VNCProxy) newProxyPromise(address, mode, username string, dest vncDestination) js.Value { @@ -288,46 +391,95 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) { p.cleanupConnection(conn) } -// sendSessionHeader writes mode, username, JWT, Windows session ID, and the -// requested viewport size to the VNC server. -// Format: [mode:1] [username_len:2] [username:N] [jwt_len:2] [jwt:N] -// -// [session_id:4] [width:2] [height:2] +// sendSessionHeader writes the NetBird VNC connection header: mode + +// username prefix, an optional Noise_IK handshake that authenticates the +// client and the server, then the trailing sessionID / width / height +// fields the daemon needs once auth is settled. func (p *VNCProxy) sendSessionHeader(conn net.Conn, dest vncDestination) error { usernameBytes := []byte(dest.username) - jwtBytes := []byte(dest.jwt) if len(usernameBytes) > 0xFFFF { return fmt.Errorf("username too long: %d bytes (max %d)", len(usernameBytes), 0xFFFF) } - if len(jwtBytes) > 0xFFFF { - return fmt.Errorf("jwt too long: %d bytes (max %d)", len(jwtBytes), 0xFFFF) - } - hdr := make([]byte, 3+len(usernameBytes)+2+len(jwtBytes)+4+4) - hdr[0] = dest.mode - hdr[1] = byte(len(usernameBytes) >> 8) - hdr[2] = byte(len(usernameBytes)) - off := 3 - copy(hdr[off:], usernameBytes) - off += len(usernameBytes) - hdr[off] = byte(len(jwtBytes) >> 8) - hdr[off+1] = byte(len(jwtBytes)) - off += 2 - copy(hdr[off:], jwtBytes) - off += len(jwtBytes) - hdr[off] = byte(dest.sessionID >> 24) - hdr[off+1] = byte(dest.sessionID >> 16) - hdr[off+2] = byte(dest.sessionID >> 8) - hdr[off+3] = byte(dest.sessionID) - off += 4 - hdr[off] = byte(dest.width >> 8) - hdr[off+1] = byte(dest.width) - hdr[off+2] = byte(dest.height >> 8) - hdr[off+3] = byte(dest.height) - - for off := 0; off < len(hdr); { - n, err := conn.Write(hdr[off:]) + prefix := make([]byte, 3+len(usernameBytes)) + prefix[0] = dest.mode + prefix[1] = byte(len(usernameBytes) >> 8) + prefix[2] = byte(len(usernameBytes)) + copy(prefix[3:], usernameBytes) + if err := writeAll(conn, prefix); err != nil { + return fmt.Errorf("write header prefix: %w", err) + } + + if dest.sessionPriv == nil { + return p.writeHeaderTail(conn, dest) + } + if err := p.runNoiseHandshake(conn, dest); err != nil { + return fmt.Errorf("noise handshake: %w", err) + } + return p.writeHeaderTail(conn, dest) +} + +// writeHeaderTail writes the post-auth trailing fields (sessionID, +// width, height) the daemon reads regardless of whether the Noise +// handshake was performed. +func (p *VNCProxy) writeHeaderTail(conn net.Conn, dest vncDestination) error { + tail := make([]byte, 4+4) + tail[0] = byte(dest.sessionID >> 24) + tail[1] = byte(dest.sessionID >> 16) + tail[2] = byte(dest.sessionID >> 8) + tail[3] = byte(dest.sessionID) + tail[4] = byte(dest.width >> 8) + tail[5] = byte(dest.width) + tail[6] = byte(dest.height >> 8) + tail[7] = byte(dest.height) + if err := writeAll(conn, tail); err != nil { + return fmt.Errorf("write header tail: %w", err) + } + return nil +} + +// runNoiseHandshake performs the initiator side of a Noise_IK handshake +// against the destination daemon. The session keypair authenticates the +// client; the daemon's pre-known peer pubkey authenticates the server. +func (p *VNCProxy) runNoiseHandshake(conn net.Conn, dest vncDestination) error { + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: true, + StaticKeypair: noise.DHKey{Private: dest.sessionPriv, Public: dest.sessionPub}, + PeerStatic: dest.peerPubKey, + }) + if err != nil { + return fmt.Errorf("noise initiator init: %w", err) + } + msg1, _, _, err := state.WriteMessage(nil, nil) + if err != nil { + return fmt.Errorf("noise write msg1: %w", err) + } + out := make([]byte, 0, len(vncIdentityMagic)+len(msg1)) + out = append(out, vncIdentityMagic...) + out = append(out, msg1...) + if err := writeAll(conn, out); err != nil { + return fmt.Errorf("send noise msg1: %w", err) + } + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + return fmt.Errorf("set noise deadline: %w", err) + } + defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck + msg2 := make([]byte, noiseResponderMsgLen) + if _, err := io.ReadFull(conn, msg2); err != nil { + return fmt.Errorf("read noise msg2: %w", err) + } + if _, _, _, err := state.ReadMessage(nil, msg2); err != nil { + return fmt.Errorf("noise read msg2: %w", err) + } + return nil +} + +func writeAll(conn net.Conn, buf []byte) error { + for off := 0; off < len(buf); { + n, err := conn.Write(buf[off:]) if err != nil { - return fmt.Errorf("write session header: %w", err) + return err } off += n } diff --git a/go.mod b/go.mod index 5f4d71291dd..a9bd785e334 100644 --- a/go.mod +++ b/go.mod @@ -185,6 +185,7 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/flynn/noise v1.1.0 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fyne-io/gl-js v0.2.0 // indirect diff --git a/go.sum b/go.sum index e8db1a191f0..49894c22faa 100644 --- a/go.sum +++ b/go.sum @@ -162,6 +162,8 @@ github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -412,6 +414,7 @@ github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoK github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -756,6 +759,7 @@ goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index ea80623b863..a33b75d2c44 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "encoding/base64" "fmt" "net/netip" "net/url" @@ -184,12 +185,47 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb if networkMap.VNCAuthorizedUsers != nil { hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.VNCAuthorizedUsers) - response.NetworkMap.VncAuth = &proto.VNCAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} + response.NetworkMap.VncAuth = &proto.VNCAuth{ + AuthorizedUsers: hashedUsers, + MachineUsers: machineUsers, + SessionPubKeys: buildSessionPubKeysProto(ctx, networkMap.VNCSessionPubKeys), + } } return response } +// buildSessionPubKeysProto decodes base64 X25519 session pubkeys and +// hashes the user IDs they belong to, emitting the proto entries the +// daemon's authorizer indexes by pubkey. +func buildSessionPubKeysProto(ctx context.Context, in []types.VNCSessionPubKey) []*proto.SessionPubKey { + if len(in) == 0 { + return nil + } + out := make([]*proto.SessionPubKey, 0, len(in)) + for _, e := range in { + pub, err := base64.StdEncoding.DecodeString(e.PubKey) + if err != nil { + log.WithContext(ctx).Warnf("decode VNC session pubkey: %v", err) + continue + } + if len(pub) != 32 { + log.WithContext(ctx).Warnf("VNC session pubkey wrong length: %d", len(pub)) + continue + } + hash, err := sshauth.HashUserID(e.UserID) + if err != nil { + log.WithContext(ctx).Warnf("hash VNC session user id: %v", err) + continue + } + out = append(out, &proto.SessionPubKey{ + PubKey: pub, + UserIdHash: hash[:], + }) + } + return out +} + func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) { userIDToIndex := make(map[string]uint32) var hashedUsers [][]byte diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 5e566d38b66..0da2db5de65 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -517,6 +517,9 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) if protocol == types.PolicyRuleProtocolNetbirdSSH || protocol == types.PolicyRuleProtocolNetbirdVNC { policy.Rules[0].AuthorizedUser = userAuth.UserId } + if protocol == types.PolicyRuleProtocolNetbirdVNC && req.SessionPubKey != nil { + policy.Rules[0].SessionPubKey = *req.SessionPubKey + } _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true) if err != nil { @@ -526,9 +529,10 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) } resp := &api.PeerTemporaryAccessResponse{ - Id: peer.ID, - Name: peer.Name, - Rules: req.Rules, + Id: peer.ID, + Name: peer.Name, + Rules: req.Rules, + TargetPubKey: targetPeer.Key, } util.WriteJSONObject(r.Context(), w, resp) diff --git a/management/server/policy_test.go b/management/server/policy_test.go index dadcd6c75ab..25dcbed9e80 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -246,14 +246,14 @@ func TestAccount_getPeersByPolicy(t *testing.T) { t.Run("check that all peers get map", func(t *testing.T) { for _, p := range account.Peers { - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers()) assert.GreaterOrEqual(t, len(peers), 1, "minimum number peers should present") assert.GreaterOrEqual(t, len(firewallRules), 1, "minimum number of firewall rules should present") } }) t.Run("check first peer map details", func(t *testing.T) { - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 8) assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerC"]) @@ -509,7 +509,7 @@ func TestAccount_getPeersByPolicy(t *testing.T) { }) t.Run("check port ranges support for older peers", func(t *testing.T) { - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 1) assert.Contains(t, peers, account.Peers["peerI"]) @@ -635,7 +635,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { } t.Run("check first peer map", func(t *testing.T) { - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerC"]) expectedFirewallRules := []*types.FirewallRule{ @@ -665,7 +665,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { }) t.Run("check second peer map", func(t *testing.T) { - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerB"]) expectedFirewallRules := []*types.FirewallRule{ @@ -697,7 +697,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { account.Policies[1].Rules[0].Bidirectional = false t.Run("check first peer map directional only", func(t *testing.T) { - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerC"]) expectedFirewallRules := []*types.FirewallRule{ @@ -719,7 +719,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { }) t.Run("check second peer map directional only", func(t *testing.T) { - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerB"]) expectedFirewallRules := []*types.FirewallRule{ @@ -917,7 +917,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { t.Run("verify peer's network map with default group peer list", func(t *testing.T) { // peerB doesn't fulfill the NB posture check but is included in the destination group Swarm, // will establish a connection with all source peers satisfying the NB posture check. - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -927,7 +927,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerC satisfy the NB posture check, should establish connection to all destination group peer's // We expect a single permissive firewall rule which all outgoing connections - peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, firewallRules, 7) expectedFirewallRules := []*types.FirewallRule{ @@ -992,7 +992,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -1002,7 +1002,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerI doesn't fulfill the OS version posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -1017,19 +1017,19 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerB doesn't satisfy the NB posture check, and doesn't exist in destination group peer's // no connection should be established to any peer of destination group - peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 0) assert.Len(t, firewallRules, 0) // peerI doesn't satisfy the OS version posture check, and doesn't exist in destination group peer's // no connection should be established to any peer of destination group - peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 0) assert.Len(t, firewallRules, 0) // peerC satisfy the NB posture check, should establish connection to all destination group peer's // We expect a single permissive firewall rule which all outgoing connections - peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, firewallRules, len(account.Groups["GroupSwarm"].Peers)) @@ -1044,14 +1044,14 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 3) assert.Len(t, firewallRules, 3) assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerC"]) assert.Contains(t, peers, account.Peers["peerD"]) - peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers()) + peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 5) // assert peers from Group Swarm assert.Contains(t, peers, account.Peers["peerD"]) diff --git a/management/server/types/account.go b/management/server/types/account.go index b39d414fdbe..5f749842e6a 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -849,7 +849,7 @@ func (a *Account) UserGroupsRemoveFromPeers(userID string, groups ...string) map // GetPeerConnectionResources for a given peer // // This function returns the list of peers and firewall rules that are applicable to a given peer. -func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, map[string]map[string]struct{}, bool) { +func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, map[string]map[string]struct{}, []VNCSessionPubKey, bool) { generateResources, getAccumulatedResources := a.connResourcesGenerator(ctx, peer) ctxState := &peerConnResolveState{ authorizedUsers: make(map[string]map[string]struct{}), @@ -869,7 +869,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P } peers, fwRules := getAccumulatedResources() - return peers, fwRules, ctxState.authorizedUsers, ctxState.vncAuthorizedUsers, ctxState.sshEnabled + return peers, fwRules, ctxState.authorizedUsers, ctxState.vncAuthorizedUsers, ctxState.vncSessionPubKeys, ctxState.sshEnabled } func (a *Account) applyPolicyRule( diff --git a/management/server/types/network.go b/management/server/types/network.go index 60236444f32..6fb11cd6617 100644 --- a/management/server/types/network.go +++ b/management/server/types/network.go @@ -49,6 +49,7 @@ type NetworkMap struct { ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} VNCAuthorizedUsers map[string]map[string]struct{} + VNCSessionPubKeys []VNCSessionPubKey EnableSSH bool } diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index a0373f0c37f..c4bc8aef6b9 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -167,6 +167,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...), AuthorizedUsers: connRes.authorizedUsers, VNCAuthorizedUsers: connRes.vncAuthorizedUsers, + VNCSessionPubKeys: connRes.vncSessionPubKeys, EnableSSH: connRes.sshEnabled, } } @@ -177,6 +178,7 @@ type peerConnectionResult struct { firewallRules []*FirewallRule authorizedUsers map[string]map[string]struct{} vncAuthorizedUsers map[string]map[string]struct{} + vncSessionPubKeys []VNCSessionPubKey sshEnabled bool } @@ -210,6 +212,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) p firewallRules: fwRules, authorizedUsers: state.authorizedUsers, vncAuthorizedUsers: state.vncAuthorizedUsers, + vncSessionPubKeys: state.vncSessionPubKeys, sshEnabled: state.sshEnabled, } } diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index c2363fffcfc..0c9dd1e4eea 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -15,9 +15,21 @@ import ( type peerConnResolveState struct { authorizedUsers map[string]map[string]struct{} vncAuthorizedUsers map[string]map[string]struct{} + vncSessionPubKeys []VNCSessionPubKey sshEnabled bool } +// VNCSessionPubKey carries an ephemeral X25519 static public key the +// dashboard registered via temporary-access. The daemon uses it as the +// allowed-client side of a Noise_IK handshake; a successful handshake +// authenticates the connection as UserID. +type VNCSessionPubKey struct { + // PubKey is the base64-encoded 32-byte X25519 public key. + PubKey string + // UserID is the unhashed user identity the pubkey authenticates as. + UserID string +} + // ruleAuthCallbacks lets Account and NetworkMapComponents share the per-rule // direction-and-auth logic while keeping their own context/state plumbing for // authorized-user collection and allowed-user lookups. @@ -57,6 +69,12 @@ func applyResolvedRuleToState( return } cb.collectVNCUsers(rule, state.vncAuthorizedUsers) + if rule.SessionPubKey != "" && rule.AuthorizedUser != "" { + state.vncSessionPubKeys = append(state.vncSessionPubKeys, VNCSessionPubKey{ + PubKey: rule.SessionPubKey, + UserID: rule.AuthorizedUser, + }) + } case policyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled: if !peerInDestinations { return diff --git a/management/server/types/policyrule.go b/management/server/types/policyrule.go index 52c494a6ad2..ceb58dd0f68 100644 --- a/management/server/types/policyrule.go +++ b/management/server/types/policyrule.go @@ -88,6 +88,12 @@ type PolicyRule struct { // AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh AuthorizedUser string + + // SessionPubKey is the base64 Ed25519 public key the AuthorizedUser + // will sign session-binding challenges with. Set together with + // AuthorizedUser when the rule was created via temporary-access for + // a VNC scope; empty otherwise. + SessionPubKey string } // Copy returns a copy of a policy rule @@ -109,6 +115,7 @@ func (pm *PolicyRule) Copy() *PolicyRule { PortRanges: make([]RulePortRange, len(pm.PortRanges)), AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)), AuthorizedUser: pm.AuthorizedUser, + SessionPubKey: pm.SessionPubKey, } copy(rule.Destinations, pm.Destinations) copy(rule.Sources, pm.Sources) @@ -136,7 +143,8 @@ func (pm *PolicyRule) Equal(other *PolicyRule) bool { pm.Protocol != other.Protocol || pm.SourceResource != other.SourceResource || pm.DestinationResource != other.DestinationResource || - pm.AuthorizedUser != other.AuthorizedUser { + pm.AuthorizedUser != other.AuthorizedUser || + pm.SessionPubKey != other.SessionPubKey { return false } diff --git a/shared/auth/jwt/token_age.go b/shared/auth/jwt/token_age.go deleted file mode 100644 index a916256565f..00000000000 --- a/shared/auth/jwt/token_age.go +++ /dev/null @@ -1,68 +0,0 @@ -package jwt - -import ( - "errors" - "fmt" - "time" - - gojwt "github.com/golang-jwt/jwt/v5" -) - -// ErrTokenExpired signals that the iat-based token age check failed. Callers -// use errors.Is to branch on it when they want to surface a stable machine- -// readable reason (e.g. so a dashboard can prompt for re-login). -var ErrTokenExpired = errors.New("token expired") - -// CheckTokenAge validates that a JWT token's iat claim is within the given -// maxAge duration. Returns an error if the claims are unparsable, the iat -// claim is missing, or the token is too old. -func CheckTokenAge(token *gojwt.Token, maxAge time.Duration) error { - if token == nil { - return fmt.Errorf("token is nil") - } - claims, ok := token.Claims.(gojwt.MapClaims) - if !ok { - return fmt.Errorf("token has invalid claims format (user=%s)", UserIDFromToken(token)) - } - - iat, ok := claims["iat"].(float64) - if !ok { - return fmt.Errorf("token missing iat claim (user=%s)", UserIDFromToken(token)) - } - - issuedAt := time.Unix(int64(iat), 0) - tokenAge := time.Since(issuedAt) - if tokenAge > maxAge { - return fmt.Errorf("%w for user=%s: age=%v, max=%v", ErrTokenExpired, userIDFromClaims(claims), tokenAge, maxAge) - } - - return nil -} - -// UserIDFromToken extracts a human-readable user identifier from a JWT token -// for use in error messages. Returns "unknown" if the token or claims are nil. -func UserIDFromToken(token *gojwt.Token) string { - if token == nil { - return "unknown" - } - claims, ok := token.Claims.(gojwt.MapClaims) - if !ok { - return "unknown" - } - return userIDFromClaims(claims) -} - -// userIDFromClaims extracts a user identifier from JWT claims, trying sub, -// user_id, and email in order. -func userIDFromClaims(claims gojwt.MapClaims) string { - if sub, ok := claims["sub"].(string); ok && sub != "" { - return sub - } - if userID, ok := claims["user_id"].(string); ok && userID != "" { - return userID - } - if email, ok := claims["email"].(string); ok && email != "" { - return email - } - return "unknown" -} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 4fde7ba94b0..049a1b26dd1 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -1007,6 +1007,10 @@ components: items: type: string example: "tcp/80" + session_pub_key: + description: Ephemeral Ed25519 public key the requester will sign session-binding challenges with. Required for VNC rules; ignored for SSH and L4. + type: string + example: "n0r3pL4c3h0ld3rK3y==" required: - name - wg_pub_key @@ -1028,10 +1032,15 @@ components: items: type: string example: "tcp/80" + target_pub_key: + description: Identity public key of the destination peer the temporary access was requested for. Used by the requester to verify the destination daemon's identity before transmitting credentials. + type: string + example: "n0r3pL4c3h0ld3rK3y==" required: - name - id - rules + - target_pub_key AccessiblePeer: allOf: - $ref: '#/components/schemas/PeerMinimum' diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 2e3b5a80b91..e7447a7c681 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -3391,6 +3391,9 @@ type PeerTemporaryAccessRequest struct { // Rules List of temporary access rules Rules []string `json:"rules"` + // SessionPubKey Ephemeral Ed25519 public key the requester will sign session-binding challenges with. Required for VNC rules; ignored for SSH and L4. + SessionPubKey *string `json:"session_pub_key,omitempty"` + // WgPubKey Peer's WireGuard public key WgPubKey string `json:"wg_pub_key"` } @@ -3405,6 +3408,9 @@ type PeerTemporaryAccessResponse struct { // Rules List of temporary access rules Rules []string `json:"rules"` + + // TargetPubKey Identity public key of the destination peer the temporary access was requested for. Used by the requester to verify the destination daemon's identity before transmitting credentials. + TargetPubKey string `json:"target_pub_key"` } // PersonalAccessToken defines model for PersonalAccessToken. diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index a720f9ec205..a4a8d17d7ea 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -424,7 +424,7 @@ func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber { // Deprecated: Use DeviceAuthorizationFlowProvider.Descriptor instead. func (DeviceAuthorizationFlowProvider) EnumDescriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{32, 0} + return file_management_proto_rawDescGZIP(), []int{33, 0} } type EncryptedMessage struct { @@ -2682,14 +2682,17 @@ type VNCAuth struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // UserIDClaim is the JWT claim to be used to get the users ID - UserIDClaim string `protobuf:"bytes,1,opt,name=UserIDClaim,proto3" json:"UserIDClaim,omitempty"` // AuthorizedUsers is a list of hashed user IDs authorized to access this peer via VNC AuthorizedUsers [][]byte `protobuf:"bytes,2,rep,name=AuthorizedUsers,proto3" json:"AuthorizedUsers,omitempty"` // MachineUsers maps OS user names to their corresponding indexes in the AuthorizedUsers list. // Used in session mode to determine which OS user to create the virtual session as. // The wildcard "*" allows any OS user. MachineUsers map[string]*MachineUserIndexes `protobuf:"bytes,3,rep,name=machine_users,json=machineUsers,proto3" json:"machine_users,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // SessionPubKeys are short-lived X25519 static keypairs the dashboard + // (or other temporary-access clients) registers per session. The + // daemon runs a Noise_IK handshake against the matching pubkey to + // authenticate the connection and resolve the pubkey back to a user. + SessionPubKeys []*SessionPubKey `protobuf:"bytes,4,rep,name=session_pub_keys,json=sessionPubKeys,proto3" json:"session_pub_keys,omitempty"` } func (x *VNCAuth) Reset() { @@ -2724,13 +2727,6 @@ func (*VNCAuth) Descriptor() ([]byte, []int) { return file_management_proto_rawDescGZIP(), []int{28} } -func (x *VNCAuth) GetUserIDClaim() string { - if x != nil { - return x.UserIDClaim - } - return "" -} - func (x *VNCAuth) GetAuthorizedUsers() [][]byte { if x != nil { return x.AuthorizedUsers @@ -2745,6 +2741,74 @@ func (x *VNCAuth) GetMachineUsers() map[string]*MachineUserIndexes { return nil } +func (x *VNCAuth) GetSessionPubKeys() []*SessionPubKey { + if x != nil { + return x.SessionPubKeys + } + return nil +} + +// SessionPubKey binds an ephemeral X25519 static public key to a hashed +// user identity so the daemon can authorize VNC connections that +// complete a Noise_IK handshake with the matching private key. +type SessionPubKey struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // PubKey is the 32-byte X25519 static public key. + PubKey []byte `protobuf:"bytes,1,opt,name=pub_key,json=pubKey,proto3" json:"pub_key,omitempty"` + // UserIDHash is the BLAKE2b-128 hash of the user ID this session + // belongs to, matching the entries in VNCAuth.AuthorizedUsers. + UserIdHash []byte `protobuf:"bytes,2,opt,name=user_id_hash,json=userIdHash,proto3" json:"user_id_hash,omitempty"` +} + +func (x *SessionPubKey) Reset() { + *x = SessionPubKey{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SessionPubKey) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionPubKey) ProtoMessage() {} + +func (x *SessionPubKey) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[29] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionPubKey.ProtoReflect.Descriptor instead. +func (*SessionPubKey) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{29} +} + +func (x *SessionPubKey) GetPubKey() []byte { + if x != nil { + return x.PubKey + } + return nil +} + +func (x *SessionPubKey) GetUserIdHash() []byte { + if x != nil { + return x.UserIdHash + } + return nil +} + // RemotePeerConfig represents a configuration of a remote peer. // The properties are used to configure WireGuard Peers sections type RemotePeerConfig struct { @@ -2766,7 +2830,7 @@ type RemotePeerConfig struct { func (x *RemotePeerConfig) Reset() { *x = RemotePeerConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[29] + mi := &file_management_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2779,7 +2843,7 @@ func (x *RemotePeerConfig) String() string { func (*RemotePeerConfig) ProtoMessage() {} func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[29] + mi := &file_management_proto_msgTypes[30] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2792,7 +2856,7 @@ func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use RemotePeerConfig.ProtoReflect.Descriptor instead. func (*RemotePeerConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{29} + return file_management_proto_rawDescGZIP(), []int{30} } func (x *RemotePeerConfig) GetWgPubKey() string { @@ -2847,7 +2911,7 @@ type SSHConfig struct { func (x *SSHConfig) Reset() { *x = SSHConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[30] + mi := &file_management_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2860,7 +2924,7 @@ func (x *SSHConfig) String() string { func (*SSHConfig) ProtoMessage() {} func (x *SSHConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[30] + mi := &file_management_proto_msgTypes[31] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2873,7 +2937,7 @@ func (x *SSHConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHConfig.ProtoReflect.Descriptor instead. func (*SSHConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{30} + return file_management_proto_rawDescGZIP(), []int{31} } func (x *SSHConfig) GetSshEnabled() bool { @@ -2907,7 +2971,7 @@ type DeviceAuthorizationFlowRequest struct { func (x *DeviceAuthorizationFlowRequest) Reset() { *x = DeviceAuthorizationFlowRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[31] + mi := &file_management_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2920,7 +2984,7 @@ func (x *DeviceAuthorizationFlowRequest) String() string { func (*DeviceAuthorizationFlowRequest) ProtoMessage() {} func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[31] + mi := &file_management_proto_msgTypes[32] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2933,7 +2997,7 @@ func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{31} + return file_management_proto_rawDescGZIP(), []int{32} } // DeviceAuthorizationFlow represents Device Authorization Flow information @@ -2952,7 +3016,7 @@ type DeviceAuthorizationFlow struct { func (x *DeviceAuthorizationFlow) Reset() { *x = DeviceAuthorizationFlow{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[32] + mi := &file_management_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2965,7 +3029,7 @@ func (x *DeviceAuthorizationFlow) String() string { func (*DeviceAuthorizationFlow) ProtoMessage() {} func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[32] + mi := &file_management_proto_msgTypes[33] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2978,7 +3042,7 @@ func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlow.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{32} + return file_management_proto_rawDescGZIP(), []int{33} } func (x *DeviceAuthorizationFlow) GetProvider() DeviceAuthorizationFlowProvider { @@ -3005,7 +3069,7 @@ type PKCEAuthorizationFlowRequest struct { func (x *PKCEAuthorizationFlowRequest) Reset() { *x = PKCEAuthorizationFlowRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[33] + mi := &file_management_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3018,7 +3082,7 @@ func (x *PKCEAuthorizationFlowRequest) String() string { func (*PKCEAuthorizationFlowRequest) ProtoMessage() {} func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[33] + mi := &file_management_proto_msgTypes[34] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3031,7 +3095,7 @@ func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{33} + return file_management_proto_rawDescGZIP(), []int{34} } // PKCEAuthorizationFlow represents Authorization Code Flow information @@ -3048,7 +3112,7 @@ type PKCEAuthorizationFlow struct { func (x *PKCEAuthorizationFlow) Reset() { *x = PKCEAuthorizationFlow{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[34] + mi := &file_management_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3061,7 +3125,7 @@ func (x *PKCEAuthorizationFlow) String() string { func (*PKCEAuthorizationFlow) ProtoMessage() {} func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[34] + mi := &file_management_proto_msgTypes[35] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3074,7 +3138,7 @@ func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlow.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{34} + return file_management_proto_rawDescGZIP(), []int{35} } func (x *PKCEAuthorizationFlow) GetProviderConfig() *ProviderConfig { @@ -3122,7 +3186,7 @@ type ProviderConfig struct { func (x *ProviderConfig) Reset() { *x = ProviderConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[35] + mi := &file_management_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3135,7 +3199,7 @@ func (x *ProviderConfig) String() string { func (*ProviderConfig) ProtoMessage() {} func (x *ProviderConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[35] + mi := &file_management_proto_msgTypes[36] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3148,7 +3212,7 @@ func (x *ProviderConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderConfig.ProtoReflect.Descriptor instead. func (*ProviderConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{35} + return file_management_proto_rawDescGZIP(), []int{36} } func (x *ProviderConfig) GetClientID() string { @@ -3257,7 +3321,7 @@ type Route struct { func (x *Route) Reset() { *x = Route{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[36] + mi := &file_management_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3270,7 +3334,7 @@ func (x *Route) String() string { func (*Route) ProtoMessage() {} func (x *Route) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[36] + mi := &file_management_proto_msgTypes[37] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3283,7 +3347,7 @@ func (x *Route) ProtoReflect() protoreflect.Message { // Deprecated: Use Route.ProtoReflect.Descriptor instead. func (*Route) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{36} + return file_management_proto_rawDescGZIP(), []int{37} } func (x *Route) GetID() string { @@ -3372,7 +3436,7 @@ type DNSConfig struct { func (x *DNSConfig) Reset() { *x = DNSConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[37] + mi := &file_management_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3385,7 +3449,7 @@ func (x *DNSConfig) String() string { func (*DNSConfig) ProtoMessage() {} func (x *DNSConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[37] + mi := &file_management_proto_msgTypes[38] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3398,7 +3462,7 @@ func (x *DNSConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use DNSConfig.ProtoReflect.Descriptor instead. func (*DNSConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{37} + return file_management_proto_rawDescGZIP(), []int{38} } func (x *DNSConfig) GetServiceEnable() bool { @@ -3447,7 +3511,7 @@ type CustomZone struct { func (x *CustomZone) Reset() { *x = CustomZone{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[38] + mi := &file_management_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3460,7 +3524,7 @@ func (x *CustomZone) String() string { func (*CustomZone) ProtoMessage() {} func (x *CustomZone) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[38] + mi := &file_management_proto_msgTypes[39] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3473,7 +3537,7 @@ func (x *CustomZone) ProtoReflect() protoreflect.Message { // Deprecated: Use CustomZone.ProtoReflect.Descriptor instead. func (*CustomZone) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{38} + return file_management_proto_rawDescGZIP(), []int{39} } func (x *CustomZone) GetDomain() string { @@ -3520,7 +3584,7 @@ type SimpleRecord struct { func (x *SimpleRecord) Reset() { *x = SimpleRecord{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[39] + mi := &file_management_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3533,7 +3597,7 @@ func (x *SimpleRecord) String() string { func (*SimpleRecord) ProtoMessage() {} func (x *SimpleRecord) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[39] + mi := &file_management_proto_msgTypes[40] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3546,7 +3610,7 @@ func (x *SimpleRecord) ProtoReflect() protoreflect.Message { // Deprecated: Use SimpleRecord.ProtoReflect.Descriptor instead. func (*SimpleRecord) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{39} + return file_management_proto_rawDescGZIP(), []int{40} } func (x *SimpleRecord) GetName() string { @@ -3599,7 +3663,7 @@ type NameServerGroup struct { func (x *NameServerGroup) Reset() { *x = NameServerGroup{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[40] + mi := &file_management_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3612,7 +3676,7 @@ func (x *NameServerGroup) String() string { func (*NameServerGroup) ProtoMessage() {} func (x *NameServerGroup) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[40] + mi := &file_management_proto_msgTypes[41] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3625,7 +3689,7 @@ func (x *NameServerGroup) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServerGroup.ProtoReflect.Descriptor instead. func (*NameServerGroup) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{40} + return file_management_proto_rawDescGZIP(), []int{41} } func (x *NameServerGroup) GetNameServers() []*NameServer { @@ -3670,7 +3734,7 @@ type NameServer struct { func (x *NameServer) Reset() { *x = NameServer{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[41] + mi := &file_management_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3683,7 +3747,7 @@ func (x *NameServer) String() string { func (*NameServer) ProtoMessage() {} func (x *NameServer) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[41] + mi := &file_management_proto_msgTypes[42] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3696,7 +3760,7 @@ func (x *NameServer) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServer.ProtoReflect.Descriptor instead. func (*NameServer) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{41} + return file_management_proto_rawDescGZIP(), []int{42} } func (x *NameServer) GetIP() string { @@ -3747,7 +3811,7 @@ type FirewallRule struct { func (x *FirewallRule) Reset() { *x = FirewallRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[42] + mi := &file_management_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3760,7 +3824,7 @@ func (x *FirewallRule) String() string { func (*FirewallRule) ProtoMessage() {} func (x *FirewallRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[42] + mi := &file_management_proto_msgTypes[43] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3773,7 +3837,7 @@ func (x *FirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use FirewallRule.ProtoReflect.Descriptor instead. func (*FirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{42} + return file_management_proto_rawDescGZIP(), []int{43} } // Deprecated: Do not use. @@ -3852,7 +3916,7 @@ type NetworkAddress struct { func (x *NetworkAddress) Reset() { *x = NetworkAddress{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[43] + mi := &file_management_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3865,7 +3929,7 @@ func (x *NetworkAddress) String() string { func (*NetworkAddress) ProtoMessage() {} func (x *NetworkAddress) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[43] + mi := &file_management_proto_msgTypes[44] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3878,7 +3942,7 @@ func (x *NetworkAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkAddress.ProtoReflect.Descriptor instead. func (*NetworkAddress) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{43} + return file_management_proto_rawDescGZIP(), []int{44} } func (x *NetworkAddress) GetNetIP() string { @@ -3906,7 +3970,7 @@ type Checks struct { func (x *Checks) Reset() { *x = Checks{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[44] + mi := &file_management_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3919,7 +3983,7 @@ func (x *Checks) String() string { func (*Checks) ProtoMessage() {} func (x *Checks) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[44] + mi := &file_management_proto_msgTypes[45] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3932,7 +3996,7 @@ func (x *Checks) ProtoReflect() protoreflect.Message { // Deprecated: Use Checks.ProtoReflect.Descriptor instead. func (*Checks) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{44} + return file_management_proto_rawDescGZIP(), []int{45} } func (x *Checks) GetFiles() []string { @@ -3957,7 +4021,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[45] + mi := &file_management_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3970,7 +4034,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[45] + mi := &file_management_proto_msgTypes[46] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3983,7 +4047,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{45} + return file_management_proto_rawDescGZIP(), []int{46} } func (m *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -4054,7 +4118,7 @@ type RouteFirewallRule struct { func (x *RouteFirewallRule) Reset() { *x = RouteFirewallRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[46] + mi := &file_management_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4067,7 +4131,7 @@ func (x *RouteFirewallRule) String() string { func (*RouteFirewallRule) ProtoMessage() {} func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[46] + mi := &file_management_proto_msgTypes[47] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4080,7 +4144,7 @@ func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RouteFirewallRule.ProtoReflect.Descriptor instead. func (*RouteFirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{46} + return file_management_proto_rawDescGZIP(), []int{47} } func (x *RouteFirewallRule) GetSourceRanges() []string { @@ -4171,7 +4235,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[47] + mi := &file_management_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4184,7 +4248,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[47] + mi := &file_management_proto_msgTypes[48] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4197,7 +4261,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{47} + return file_management_proto_rawDescGZIP(), []int{48} } func (x *ForwardingRule) GetProtocol() RuleProtocol { @@ -4246,7 +4310,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[48] + mi := &file_management_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4259,7 +4323,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[48] + mi := &file_management_proto_msgTypes[49] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4272,7 +4336,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{48} + return file_management_proto_rawDescGZIP(), []int{49} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -4345,7 +4409,7 @@ type ExposeServiceResponse struct { func (x *ExposeServiceResponse) Reset() { *x = ExposeServiceResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[49] + mi := &file_management_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4358,7 +4422,7 @@ func (x *ExposeServiceResponse) String() string { func (*ExposeServiceResponse) ProtoMessage() {} func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[49] + mi := &file_management_proto_msgTypes[50] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4371,7 +4435,7 @@ func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceResponse.ProtoReflect.Descriptor instead. func (*ExposeServiceResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{49} + return file_management_proto_rawDescGZIP(), []int{50} } func (x *ExposeServiceResponse) GetServiceName() string { @@ -4413,7 +4477,7 @@ type RenewExposeRequest struct { func (x *RenewExposeRequest) Reset() { *x = RenewExposeRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[50] + mi := &file_management_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4426,7 +4490,7 @@ func (x *RenewExposeRequest) String() string { func (*RenewExposeRequest) ProtoMessage() {} func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[50] + mi := &file_management_proto_msgTypes[51] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4439,7 +4503,7 @@ func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeRequest.ProtoReflect.Descriptor instead. func (*RenewExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{50} + return file_management_proto_rawDescGZIP(), []int{51} } func (x *RenewExposeRequest) GetDomain() string { @@ -4458,7 +4522,7 @@ type RenewExposeResponse struct { func (x *RenewExposeResponse) Reset() { *x = RenewExposeResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[51] + mi := &file_management_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4471,7 +4535,7 @@ func (x *RenewExposeResponse) String() string { func (*RenewExposeResponse) ProtoMessage() {} func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[51] + mi := &file_management_proto_msgTypes[52] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4484,7 +4548,7 @@ func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeResponse.ProtoReflect.Descriptor instead. func (*RenewExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{51} + return file_management_proto_rawDescGZIP(), []int{52} } type StopExposeRequest struct { @@ -4498,7 +4562,7 @@ type StopExposeRequest struct { func (x *StopExposeRequest) Reset() { *x = StopExposeRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[52] + mi := &file_management_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4511,7 +4575,7 @@ func (x *StopExposeRequest) String() string { func (*StopExposeRequest) ProtoMessage() {} func (x *StopExposeRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[52] + mi := &file_management_proto_msgTypes[53] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4524,7 +4588,7 @@ func (x *StopExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeRequest.ProtoReflect.Descriptor instead. func (*StopExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{52} + return file_management_proto_rawDescGZIP(), []int{53} } func (x *StopExposeRequest) GetDomain() string { @@ -4543,7 +4607,7 @@ type StopExposeResponse struct { func (x *StopExposeResponse) Reset() { *x = StopExposeResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[53] + mi := &file_management_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4556,7 +4620,7 @@ func (x *StopExposeResponse) String() string { func (*StopExposeResponse) ProtoMessage() {} func (x *StopExposeResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[53] + mi := &file_management_proto_msgTypes[54] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4569,7 +4633,7 @@ func (x *StopExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeResponse.ProtoReflect.Descriptor instead. func (*StopExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{53} + return file_management_proto_rawDescGZIP(), []int{54} } type PortInfo_Range struct { @@ -4584,7 +4648,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[56] + mi := &file_management_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4597,7 +4661,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[56] + mi := &file_management_proto_msgTypes[57] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4610,7 +4674,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead. func (*PortInfo_Range) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{45, 0} + return file_management_proto_rawDescGZIP(), []int{46, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -5009,357 +5073,364 @@ var file_management_proto_rawDesc = []byte{ 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, - 0x82, 0x02, 0x0a, 0x07, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, - 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, - 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, - 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, - 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, - 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, - 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, - 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, - 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, - 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, - 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, - 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, - 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, - 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, - 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, - 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, - 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, - 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, - 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, - 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, - 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, - 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, - 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, - 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, - 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, + 0xa5, 0x02, 0x0a, 0x07, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x0f, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, + 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, + 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, + 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x12, 0x43, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x75, 0x62, + 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x52, 0x0e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50, + 0x75, 0x62, 0x4b, 0x65, 0x79, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, + 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, + 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x4a, 0x0a, 0x0d, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x75, 0x62, 0x5f, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, + 0x79, 0x12, 0x20, 0x0a, 0x0c, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x68, 0x61, 0x73, + 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, + 0x61, 0x73, 0x68, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, + 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, + 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, + 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, + 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, + 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, + 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, + 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, + 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, + 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, + 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, - 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, - 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, - 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, - 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, - 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, - 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, - 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, - 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, - 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, - 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, - 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, - 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, - 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, - 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, - 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, - 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, - 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, - 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, - 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, - 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, - 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, - 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, - 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, - 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, - 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, - 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, - 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, - 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, - 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, - 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, - 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, - 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, - 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, - 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, - 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, - 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, - 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, - 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, - 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, - 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, - 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, - 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, - 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, - 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, - 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, - 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, - 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, - 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, - 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, - 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, - 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, - 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, - 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, - 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, - 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, - 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, - 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, - 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, - 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, - 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, - 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, - 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, - 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, - 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, - 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, - 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, - 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, - 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, - 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, - 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, - 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, - 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, - 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, - 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, - 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, - 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, + 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, + 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, + 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, + 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, + 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, + 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, + 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, + 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, + 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, + 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, + 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, + 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, + 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, + 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, + 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, + 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, + 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, + 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, + 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, + 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, + 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, + 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, + 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, + 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, + 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, + 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, + 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, + 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, + 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, + 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, + 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, + 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, + 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, + 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, + 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, + 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, + 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, + 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, + 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, + 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, + 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, + 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, + 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, + 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, + 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, + 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, + 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, + 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, + 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, + 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, + 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, - 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, - 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, - 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, - 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, - 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, - 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, - 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, - 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, - 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, - 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, - 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, - 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, - 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, - 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, - 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, - 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, - 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, - 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, - 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, - 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, - 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, - 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, - 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, - 0x65, 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, - 0x2a, 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, - 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, - 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, - 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, - 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, - 0x79, 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x2a, 0x4c, - 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, - 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, - 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, - 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, - 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, - 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, - 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, - 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, - 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, - 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, - 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, - 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, - 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, - 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, - 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, - 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, + 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, + 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, + 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, + 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, + 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, + 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, + 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, + 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, + 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, + 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, + 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, + 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, + 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, + 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, + 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, + 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, + 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, + 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, + 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, + 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, + 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, + 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, + 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, + 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, + 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, + 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, + 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, + 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, + 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, + 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, + 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, + 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, + 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, + 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, + 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, + 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, + 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, + 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, + 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, + 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, + 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, + 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, + 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, + 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, + 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x2a, 0x4c, 0x0a, + 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, + 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, + 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, + 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, + 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52, + 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, + 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, + 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, + 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, + 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, + 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, + 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, + 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, + 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, + 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, + 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, + 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, + 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, + 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, + 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, + 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, - 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, - 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, - 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, - 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, - 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, + 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, + 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, + 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, - 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, - 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, - 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, - 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, + 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, - 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, - 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, + 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, + 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -5375,7 +5446,7 @@ func file_management_proto_rawDescGZIP() []byte { } var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 57) +var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 58) var file_management_proto_goTypes = []interface{}{ (JobStatus)(0), // 0: management.JobStatus (PeerCapability)(0), // 1: management.PeerCapability @@ -5414,36 +5485,37 @@ var file_management_proto_goTypes = []interface{}{ (*SSHAuth)(nil), // 34: management.SSHAuth (*MachineUserIndexes)(nil), // 35: management.MachineUserIndexes (*VNCAuth)(nil), // 36: management.VNCAuth - (*RemotePeerConfig)(nil), // 37: management.RemotePeerConfig - (*SSHConfig)(nil), // 38: management.SSHConfig - (*DeviceAuthorizationFlowRequest)(nil), // 39: management.DeviceAuthorizationFlowRequest - (*DeviceAuthorizationFlow)(nil), // 40: management.DeviceAuthorizationFlow - (*PKCEAuthorizationFlowRequest)(nil), // 41: management.PKCEAuthorizationFlowRequest - (*PKCEAuthorizationFlow)(nil), // 42: management.PKCEAuthorizationFlow - (*ProviderConfig)(nil), // 43: management.ProviderConfig - (*Route)(nil), // 44: management.Route - (*DNSConfig)(nil), // 45: management.DNSConfig - (*CustomZone)(nil), // 46: management.CustomZone - (*SimpleRecord)(nil), // 47: management.SimpleRecord - (*NameServerGroup)(nil), // 48: management.NameServerGroup - (*NameServer)(nil), // 49: management.NameServer - (*FirewallRule)(nil), // 50: management.FirewallRule - (*NetworkAddress)(nil), // 51: management.NetworkAddress - (*Checks)(nil), // 52: management.Checks - (*PortInfo)(nil), // 53: management.PortInfo - (*RouteFirewallRule)(nil), // 54: management.RouteFirewallRule - (*ForwardingRule)(nil), // 55: management.ForwardingRule - (*ExposeServiceRequest)(nil), // 56: management.ExposeServiceRequest - (*ExposeServiceResponse)(nil), // 57: management.ExposeServiceResponse - (*RenewExposeRequest)(nil), // 58: management.RenewExposeRequest - (*RenewExposeResponse)(nil), // 59: management.RenewExposeResponse - (*StopExposeRequest)(nil), // 60: management.StopExposeRequest - (*StopExposeResponse)(nil), // 61: management.StopExposeResponse - nil, // 62: management.SSHAuth.MachineUsersEntry - nil, // 63: management.VNCAuth.MachineUsersEntry - (*PortInfo_Range)(nil), // 64: management.PortInfo.Range - (*timestamppb.Timestamp)(nil), // 65: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 66: google.protobuf.Duration + (*SessionPubKey)(nil), // 37: management.SessionPubKey + (*RemotePeerConfig)(nil), // 38: management.RemotePeerConfig + (*SSHConfig)(nil), // 39: management.SSHConfig + (*DeviceAuthorizationFlowRequest)(nil), // 40: management.DeviceAuthorizationFlowRequest + (*DeviceAuthorizationFlow)(nil), // 41: management.DeviceAuthorizationFlow + (*PKCEAuthorizationFlowRequest)(nil), // 42: management.PKCEAuthorizationFlowRequest + (*PKCEAuthorizationFlow)(nil), // 43: management.PKCEAuthorizationFlow + (*ProviderConfig)(nil), // 44: management.ProviderConfig + (*Route)(nil), // 45: management.Route + (*DNSConfig)(nil), // 46: management.DNSConfig + (*CustomZone)(nil), // 47: management.CustomZone + (*SimpleRecord)(nil), // 48: management.SimpleRecord + (*NameServerGroup)(nil), // 49: management.NameServerGroup + (*NameServer)(nil), // 50: management.NameServer + (*FirewallRule)(nil), // 51: management.FirewallRule + (*NetworkAddress)(nil), // 52: management.NetworkAddress + (*Checks)(nil), // 53: management.Checks + (*PortInfo)(nil), // 54: management.PortInfo + (*RouteFirewallRule)(nil), // 55: management.RouteFirewallRule + (*ForwardingRule)(nil), // 56: management.ForwardingRule + (*ExposeServiceRequest)(nil), // 57: management.ExposeServiceRequest + (*ExposeServiceResponse)(nil), // 58: management.ExposeServiceResponse + (*RenewExposeRequest)(nil), // 59: management.RenewExposeRequest + (*RenewExposeResponse)(nil), // 60: management.RenewExposeResponse + (*StopExposeRequest)(nil), // 61: management.StopExposeRequest + (*StopExposeResponse)(nil), // 62: management.StopExposeResponse + nil, // 63: management.SSHAuth.MachineUsersEntry + nil, // 64: management.VNCAuth.MachineUsersEntry + (*PortInfo_Range)(nil), // 65: management.PortInfo.Range + (*timestamppb.Timestamp)(nil), // 66: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 67: google.protobuf.Duration } var file_management_proto_depIdxs = []int32{ 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters @@ -5452,95 +5524,96 @@ var file_management_proto_depIdxs = []int32{ 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta 25, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig 31, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig - 37, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig + 38, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig 33, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap - 52, // 8: management.SyncResponse.Checks:type_name -> management.Checks + 53, // 8: management.SyncResponse.Checks:type_name -> management.Checks 21, // 9: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta 21, // 10: management.LoginRequest.meta:type_name -> management.PeerSystemMeta 17, // 11: management.LoginRequest.peerKeys:type_name -> management.PeerKeys - 51, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress + 52, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress 18, // 13: management.PeerSystemMeta.environment:type_name -> management.Environment 19, // 14: management.PeerSystemMeta.files:type_name -> management.File 20, // 15: management.PeerSystemMeta.flags:type_name -> management.Flags 1, // 16: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability 25, // 17: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig 31, // 18: management.LoginResponse.peerConfig:type_name -> management.PeerConfig - 52, // 19: management.LoginResponse.Checks:type_name -> management.Checks - 65, // 20: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp + 53, // 19: management.LoginResponse.Checks:type_name -> management.Checks + 66, // 20: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp 26, // 21: management.NetbirdConfig.stuns:type_name -> management.HostConfig 30, // 22: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig 26, // 23: management.NetbirdConfig.signal:type_name -> management.HostConfig 27, // 24: management.NetbirdConfig.relay:type_name -> management.RelayConfig 28, // 25: management.NetbirdConfig.flow:type_name -> management.FlowConfig 6, // 26: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol - 66, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration + 67, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration 26, // 28: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig - 38, // 29: management.PeerConfig.sshConfig:type_name -> management.SSHConfig + 39, // 29: management.PeerConfig.sshConfig:type_name -> management.SSHConfig 32, // 30: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings 31, // 31: management.NetworkMap.peerConfig:type_name -> management.PeerConfig - 37, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig - 44, // 33: management.NetworkMap.Routes:type_name -> management.Route - 45, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig - 37, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig - 50, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule - 54, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule - 55, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule + 38, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig + 45, // 33: management.NetworkMap.Routes:type_name -> management.Route + 46, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig + 38, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig + 51, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule + 55, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule + 56, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule 34, // 39: management.NetworkMap.sshAuth:type_name -> management.SSHAuth 36, // 40: management.NetworkMap.vncAuth:type_name -> management.VNCAuth - 62, // 41: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry - 63, // 42: management.VNCAuth.machine_users:type_name -> management.VNCAuth.MachineUsersEntry - 38, // 43: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig - 29, // 44: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig - 7, // 45: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider - 43, // 46: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 43, // 47: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 48, // 48: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup - 46, // 49: management.DNSConfig.CustomZones:type_name -> management.CustomZone - 47, // 50: management.CustomZone.Records:type_name -> management.SimpleRecord - 49, // 51: management.NameServerGroup.NameServers:type_name -> management.NameServer - 3, // 52: management.FirewallRule.Direction:type_name -> management.RuleDirection - 4, // 53: management.FirewallRule.Action:type_name -> management.RuleAction - 2, // 54: management.FirewallRule.Protocol:type_name -> management.RuleProtocol - 53, // 55: management.FirewallRule.PortInfo:type_name -> management.PortInfo - 64, // 56: management.PortInfo.range:type_name -> management.PortInfo.Range - 4, // 57: management.RouteFirewallRule.action:type_name -> management.RuleAction - 2, // 58: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol - 53, // 59: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo - 2, // 60: management.ForwardingRule.protocol:type_name -> management.RuleProtocol - 53, // 61: management.ForwardingRule.destinationPort:type_name -> management.PortInfo - 53, // 62: management.ForwardingRule.translatedPort:type_name -> management.PortInfo - 5, // 63: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol - 35, // 64: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 35, // 65: management.VNCAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 8, // 66: management.ManagementService.Login:input_type -> management.EncryptedMessage - 8, // 67: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 24, // 68: management.ManagementService.GetServerKey:input_type -> management.Empty - 24, // 69: management.ManagementService.isHealthy:input_type -> management.Empty - 8, // 70: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 71: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 72: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 8, // 73: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 8, // 74: management.ManagementService.Job:input_type -> management.EncryptedMessage - 8, // 75: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 8, // 76: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 8, // 77: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 8, // 78: management.ManagementService.Login:output_type -> management.EncryptedMessage - 8, // 79: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 23, // 80: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 24, // 81: management.ManagementService.isHealthy:output_type -> management.Empty - 8, // 82: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 8, // 83: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 24, // 84: management.ManagementService.SyncMeta:output_type -> management.Empty - 24, // 85: management.ManagementService.Logout:output_type -> management.Empty - 8, // 86: management.ManagementService.Job:output_type -> management.EncryptedMessage - 8, // 87: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 8, // 88: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 8, // 89: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 78, // [78:90] is the sub-list for method output_type - 66, // [66:78] is the sub-list for method input_type - 66, // [66:66] is the sub-list for extension type_name - 66, // [66:66] is the sub-list for extension extendee - 0, // [0:66] is the sub-list for field type_name + 63, // 41: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry + 64, // 42: management.VNCAuth.machine_users:type_name -> management.VNCAuth.MachineUsersEntry + 37, // 43: management.VNCAuth.session_pub_keys:type_name -> management.SessionPubKey + 39, // 44: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig + 29, // 45: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig + 7, // 46: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider + 44, // 47: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 44, // 48: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 49, // 49: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup + 47, // 50: management.DNSConfig.CustomZones:type_name -> management.CustomZone + 48, // 51: management.CustomZone.Records:type_name -> management.SimpleRecord + 50, // 52: management.NameServerGroup.NameServers:type_name -> management.NameServer + 3, // 53: management.FirewallRule.Direction:type_name -> management.RuleDirection + 4, // 54: management.FirewallRule.Action:type_name -> management.RuleAction + 2, // 55: management.FirewallRule.Protocol:type_name -> management.RuleProtocol + 54, // 56: management.FirewallRule.PortInfo:type_name -> management.PortInfo + 65, // 57: management.PortInfo.range:type_name -> management.PortInfo.Range + 4, // 58: management.RouteFirewallRule.action:type_name -> management.RuleAction + 2, // 59: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol + 54, // 60: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo + 2, // 61: management.ForwardingRule.protocol:type_name -> management.RuleProtocol + 54, // 62: management.ForwardingRule.destinationPort:type_name -> management.PortInfo + 54, // 63: management.ForwardingRule.translatedPort:type_name -> management.PortInfo + 5, // 64: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol + 35, // 65: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 35, // 66: management.VNCAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 8, // 67: management.ManagementService.Login:input_type -> management.EncryptedMessage + 8, // 68: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 24, // 69: management.ManagementService.GetServerKey:input_type -> management.Empty + 24, // 70: management.ManagementService.isHealthy:input_type -> management.Empty + 8, // 71: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 72: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 73: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 8, // 74: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 8, // 75: management.ManagementService.Job:input_type -> management.EncryptedMessage + 8, // 76: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 8, // 77: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 8, // 78: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 8, // 79: management.ManagementService.Login:output_type -> management.EncryptedMessage + 8, // 80: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 23, // 81: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 24, // 82: management.ManagementService.isHealthy:output_type -> management.Empty + 8, // 83: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 8, // 84: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 24, // 85: management.ManagementService.SyncMeta:output_type -> management.Empty + 24, // 86: management.ManagementService.Logout:output_type -> management.Empty + 8, // 87: management.ManagementService.Job:output_type -> management.EncryptedMessage + 8, // 88: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 8, // 89: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 8, // 90: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 79, // [79:91] is the sub-list for method output_type + 67, // [67:79] is the sub-list for method input_type + 67, // [67:67] is the sub-list for extension type_name + 67, // [67:67] is the sub-list for extension extendee + 0, // [0:67] is the sub-list for field type_name } func init() { file_management_proto_init() } @@ -5898,7 +5971,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RemotePeerConfig); i { + switch v := v.(*SessionPubKey); i { case 0: return &v.state case 1: @@ -5910,7 +5983,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SSHConfig); i { + switch v := v.(*RemotePeerConfig); i { case 0: return &v.state case 1: @@ -5922,7 +5995,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlowRequest); i { + switch v := v.(*SSHConfig); i { case 0: return &v.state case 1: @@ -5934,7 +6007,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlow); i { + switch v := v.(*DeviceAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -5946,7 +6019,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlowRequest); i { + switch v := v.(*DeviceAuthorizationFlow); i { case 0: return &v.state case 1: @@ -5958,7 +6031,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlow); i { + switch v := v.(*PKCEAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -5970,7 +6043,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProviderConfig); i { + switch v := v.(*PKCEAuthorizationFlow); i { case 0: return &v.state case 1: @@ -5982,7 +6055,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Route); i { + switch v := v.(*ProviderConfig); i { case 0: return &v.state case 1: @@ -5994,7 +6067,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DNSConfig); i { + switch v := v.(*Route); i { case 0: return &v.state case 1: @@ -6006,7 +6079,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CustomZone); i { + switch v := v.(*DNSConfig); i { case 0: return &v.state case 1: @@ -6018,7 +6091,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SimpleRecord); i { + switch v := v.(*CustomZone); i { case 0: return &v.state case 1: @@ -6030,7 +6103,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServerGroup); i { + switch v := v.(*SimpleRecord); i { case 0: return &v.state case 1: @@ -6042,7 +6115,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServer); i { + switch v := v.(*NameServerGroup); i { case 0: return &v.state case 1: @@ -6054,7 +6127,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FirewallRule); i { + switch v := v.(*NameServer); i { case 0: return &v.state case 1: @@ -6066,7 +6139,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkAddress); i { + switch v := v.(*FirewallRule); i { case 0: return &v.state case 1: @@ -6078,7 +6151,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Checks); i { + switch v := v.(*NetworkAddress); i { case 0: return &v.state case 1: @@ -6090,7 +6163,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PortInfo); i { + switch v := v.(*Checks); i { case 0: return &v.state case 1: @@ -6102,7 +6175,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RouteFirewallRule); i { + switch v := v.(*PortInfo); i { case 0: return &v.state case 1: @@ -6114,7 +6187,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ForwardingRule); i { + switch v := v.(*RouteFirewallRule); i { case 0: return &v.state case 1: @@ -6126,7 +6199,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceRequest); i { + switch v := v.(*ForwardingRule); i { case 0: return &v.state case 1: @@ -6138,7 +6211,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceResponse); i { + switch v := v.(*ExposeServiceRequest); i { case 0: return &v.state case 1: @@ -6150,7 +6223,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[50].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeRequest); i { + switch v := v.(*ExposeServiceResponse); i { case 0: return &v.state case 1: @@ -6162,7 +6235,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[51].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeResponse); i { + switch v := v.(*RenewExposeRequest); i { case 0: return &v.state case 1: @@ -6174,7 +6247,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[52].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopExposeRequest); i { + switch v := v.(*RenewExposeResponse); i { case 0: return &v.state case 1: @@ -6186,6 +6259,18 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[53].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StopExposeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[54].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StopExposeResponse); i { case 0: return &v.state @@ -6197,7 +6282,7 @@ func file_management_proto_init() { return nil } } - file_management_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} { + file_management_proto_msgTypes[57].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*PortInfo_Range); i { case 0: return &v.state @@ -6216,7 +6301,7 @@ func file_management_proto_init() { file_management_proto_msgTypes[2].OneofWrappers = []interface{}{ (*JobResponse_Bundle)(nil), } - file_management_proto_msgTypes[45].OneofWrappers = []interface{}{ + file_management_proto_msgTypes[46].OneofWrappers = []interface{}{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } @@ -6226,7 +6311,7 @@ func file_management_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_management_proto_rawDesc, NumEnums: 8, - NumMessages: 57, + NumMessages: 58, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index e610312f9d2..756cdcbbbeb 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -428,9 +428,6 @@ message MachineUserIndexes { // VNCAuth represents VNC authorization configuration for a peer. message VNCAuth { - // UserIDClaim is the JWT claim to be used to get the users ID - string UserIDClaim = 1; - // AuthorizedUsers is a list of hashed user IDs authorized to access this peer via VNC repeated bytes AuthorizedUsers = 2; @@ -438,6 +435,24 @@ message VNCAuth { // Used in session mode to determine which OS user to create the virtual session as. // The wildcard "*" allows any OS user. map machine_users = 3; + + // SessionPubKeys are short-lived X25519 static keypairs the dashboard + // (or other temporary-access clients) registers per session. The + // daemon runs a Noise_IK handshake against the matching pubkey to + // authenticate the connection and resolve the pubkey back to a user. + repeated SessionPubKey session_pub_keys = 4; +} + +// SessionPubKey binds an ephemeral X25519 static public key to a hashed +// user identity so the daemon can authorize VNC connections that +// complete a Noise_IK handshake with the matching private key. +message SessionPubKey { + // PubKey is the 32-byte X25519 static public key. + bytes pub_key = 1; + + // UserIDHash is the BLAKE2b-128 hash of the user ID this session + // belongs to, matching the entries in VNCAuth.AuthorizedUsers. + bytes user_id_hash = 2; } // RemotePeerConfig represents a configuration of a remote peer. From ee348ba007cc07fec07851692191afb8641d2e7e Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 21 May 2026 17:44:22 +0200 Subject: [PATCH 069/151] Abort VNC agent dial retry loop on server shutdown --- client/vnc/server/agent_ipc.go | 31 +++++++++++++++++++++++------ client/vnc/server/server_darwin.go | 2 +- client/vnc/server/server_windows.go | 2 +- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index bc1eab62ffb..7645201fdc3 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -4,9 +4,11 @@ package server import ( "bufio" + "context" crand "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" @@ -55,11 +57,11 @@ func generateAuthToken() (string, error) { // side closes. The token has to land on the wire before any VNC byte so // the agent's listening Server can apply verifyAgentToken before letting // real RFB traffic through. -func proxyToAgent(client net.Conn, port uint16, authToken string) { +func proxyToAgent(ctx context.Context, client net.Conn, port uint16, authToken string) { defer client.Close() addr := fmt.Sprintf("127.0.0.1:%d", port) - agentConn, err := dialAgentWithRetry(addr) + agentConn, err := dialAgentWithRetry(ctx, addr) if err != nil { log.Warnf("proxy cannot reach agent at %s: %v", addr, err) return @@ -144,16 +146,33 @@ func relogAgentStream(r io.Reader) { // dialAgentWithRetry retries the loopback connect for up to ~10 s so the // daemon does not race the agent's first listen. Returns the live conn or -// the final error. -func dialAgentWithRetry(addr string) (net.Conn, error) { +// the final error. Aborts early when ctx is cancelled so a Stop() during +// service-mode startup doesn't leave a goroutine sleeping for 10 s. +func dialAgentWithRetry(ctx context.Context, addr string) (net.Conn, error) { + var d net.Dialer var lastErr error for range 50 { - c, err := net.DialTimeout("tcp", addr, time.Second) + if err := ctx.Err(); err != nil { + if lastErr == nil { + lastErr = err + } + return nil, lastErr + } + dialCtx, cancel := context.WithTimeout(ctx, time.Second) + c, err := d.DialContext(dialCtx, "tcp", addr) + cancel() if err == nil { return c, nil } lastErr = err - time.Sleep(200 * time.Millisecond) + select { + case <-ctx.Done(): + if errors.Is(lastErr, context.Canceled) || errors.Is(lastErr, context.DeadlineExceeded) { + lastErr = ctx.Err() + } + return nil, lastErr + case <-time.After(200 * time.Millisecond): + } } return nil, lastErr } diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index af77fe63dbd..81c0ca9916b 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -106,7 +106,7 @@ func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentMa Reader: io.MultiReader(&headerBuf, conn), Conn: conn, } - proxyToAgent(replayConn, agentPort, token) + proxyToAgent(s.ctx, replayConn, agentPort, token) } // darwinPrefixConn replays the already-consumed connection-header bytes diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 70cbdf16e75..46e1181e7e9 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -308,7 +308,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) { Reader: io.MultiReader(&headerBuf, conn), Conn: conn, } - proxyToAgent(replayConn, agentPort, sm.AuthToken()) + proxyToAgent(s.ctx, replayConn, agentPort, sm.AuthToken()) } // prefixConn wraps a net.Conn, overriding Read to use a different reader. From 5e67febf57f1a637e53242484743c63ca3c9c413 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 21 May 2026 17:55:27 +0200 Subject: [PATCH 070/151] Address Sonar findings and move noise to direct dependency --- client/vnc/server/input_darwin.go | 31 ++++++++++--------- client/vnc/server/server.go | 2 +- go.mod | 2 +- .../server/types/policy_authorized_users.go | 27 +++++++++------- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index a144ae4e29e..b1b4e992634 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -465,23 +465,26 @@ func postFnFlagsChanged(src uintptr, fnOn bool) { cfRelease(event) } +// fnShiftedKeycodes are the Apple navigation/edit keys that hardware produces +// with the Fn modifier held. +var fnShiftedKeycodes = map[uint16]struct{}{ + 0x72: {}, // Help / Insert + 0x73: {}, // Home + 0x74: {}, // PageUp + 0x75: {}, // ForwardDelete + 0x77: {}, // End + 0x79: {}, // PageDown + 0x7B: {}, // Left + 0x7C: {}, // Right + 0x7D: {}, // Down + 0x7E: {}, // Up +} + // isFnShiftedKeycode reports whether keycode is one of the Apple // navigation/edit keys that hardware produces with the Fn modifier held. func isFnShiftedKeycode(keycode uint16) bool { - switch keycode { - case 0x72, // Help / Insert - 0x73, // Home - 0x74, // PageUp - 0x75, // ForwardDelete - 0x77, // End - 0x79, // PageDown - 0x7B, // Left - 0x7C, // Right - 0x7D, // Down - 0x7E: // Up - return true - } - return false + _, ok := fnShiftedKeycodes[keycode] + return ok } // InjectPointer simulates mouse movement and button events. diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 08e8f8cbe7f..09112ce5700 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -1038,7 +1038,7 @@ func (s *Server) acquireAttachSession() (ScreenCapturer, func()) { cc.ClientConnect() return s.capturer, cc.ClientDisconnect } - return s.capturer, func() {} + return s.capturer, func() { /* capturer has no per-client disconnect hook */ } } // modeString returns a human-readable session mode name. diff --git a/go.mod b/go.mod index a9bd785e334..dab8623d951 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/eko/gocache/lib/v4 v4.2.0 github.com/eko/gocache/store/go_cache/v4 v4.2.2 github.com/eko/gocache/store/redis/v4 v4.2.2 + github.com/flynn/noise v1.1.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 github.com/go-jose/go-jose/v4 v4.1.4 @@ -185,7 +186,6 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/flynn/noise v1.1.0 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fyne-io/gl-js v0.2.0 // indirect diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index 0c9dd1e4eea..16337590f4b 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -64,17 +64,7 @@ func applyResolvedRuleToState( state.sshEnabled = true cb.collectSSHUsers(rule, state.authorizedUsers) case rule.Protocol == PolicyRuleProtocolNetbirdVNC: - // VNC bidirectional rules grant access in both directions. - if !peerInDestinations && !(rule.Bidirectional && peerInSources) { - return - } - cb.collectVNCUsers(rule, state.vncAuthorizedUsers) - if rule.SessionPubKey != "" && rule.AuthorizedUser != "" { - state.vncSessionPubKeys = append(state.vncSessionPubKeys, VNCSessionPubKey{ - PubKey: rule.SessionPubKey, - UserID: rule.AuthorizedUser, - }) - } + cb.handleVNCRule(rule, peerInSources, peerInDestinations, state) case policyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled: if !peerInDestinations { return @@ -84,6 +74,21 @@ func applyResolvedRuleToState( } } +// handleVNCRule collects VNC authorized users and session pubkeys for a VNC +// policy rule. Bidirectional rules grant access in both directions. +func (cb ruleAuthCallbacks) handleVNCRule(rule *PolicyRule, peerInSources, peerInDestinations bool, state *peerConnResolveState) { + if !peerInDestinations && !(rule.Bidirectional && peerInSources) { + return + } + cb.collectVNCUsers(rule, state.vncAuthorizedUsers) + if rule.SessionPubKey != "" && rule.AuthorizedUser != "" { + state.vncSessionPubKeys = append(state.vncSessionPubKeys, VNCSessionPubKey{ + PubKey: rule.SessionPubKey, + UserID: rule.AuthorizedUser, + }) + } +} + func mergeWildcardUsers(dst map[string]map[string]struct{}, users map[string]struct{}) { if dst[auth.Wildcard] == nil { dst[auth.Wildcard] = make(map[string]struct{}) From 412193c602c6a2bf7697d8c92d0154d1a45ae792 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 21 May 2026 18:09:07 +0200 Subject: [PATCH 071/151] Address CodeRabbit VNC review feedback --- client/ssh/auth/auth.go | 10 +++++++++ client/status/status.go | 2 ++ client/vnc/server/input_uinput_freebsd.go | 17 ++++++++++++++ client/vnc/server/input_windows.go | 4 ---- client/vnc/server/noise_auth_test.go | 13 ++++++----- client/wasm/internal/vnc/proxy.go | 22 +++++++------------ .../http/handlers/peers/peers_handler.go | 10 +++++++++ management/server/types/policyrule.go | 8 +++---- shared/management/http/api/openapi.yml | 2 +- 9 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 client/vnc/server/input_uinput_freebsd.go diff --git a/client/ssh/auth/auth.go b/client/ssh/auth/auth.go index 52a548a8111..782c816af6b 100644 --- a/client/ssh/auth/auth.go +++ b/client/ssh/auth/auth.go @@ -117,12 +117,22 @@ func (a *Authorizer) Update(config *Config) { a.machineUsers = machineUsers sessionPubKeys := make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash, len(config.SessionPubKeys)) + conflicted := make(map[[sessionPubKeyLen]byte]struct{}) for _, e := range config.SessionPubKeys { if len(e.PubKey) != sessionPubKeyLen { continue } var key [sessionPubKeyLen]byte copy(key[:], e.PubKey) + if _, bad := conflicted[key]; bad { + continue + } + if existing, ok := sessionPubKeys[key]; ok && existing != e.UserIDHash { + log.Warnf("SSH auth: session pubkey bound to conflicting user hashes; dropping binding") + delete(sessionPubKeys, key) + conflicted[key] = struct{}{} + continue + } sessionPubKeys[key] = e.UserIDHash } a.sessionPubKeys = sessionPubKeys diff --git a/client/status/status.go b/client/status/status.go index d3aaadf51f1..30760efe15a 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -1075,5 +1075,7 @@ func anonymizeServerSessions(a *anonymize.Anonymizer, overview *OutputOverview) } for i, sess := range overview.VNCServerState.Sessions { overview.VNCServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, sess.RemoteAddress) + overview.VNCServerState.Sessions[i].Username = a.AnonymizeString(sess.Username) + overview.VNCServerState.Sessions[i].UserID = a.AnonymizeString(sess.UserID) } } diff --git a/client/vnc/server/input_uinput_freebsd.go b/client/vnc/server/input_uinput_freebsd.go new file mode 100644 index 00000000000..08ccc32b248 --- /dev/null +++ b/client/vnc/server/input_uinput_freebsd.go @@ -0,0 +1,17 @@ +//go:build freebsd + +package server + +import "fmt" + +// UInputInjector is a freebsd placeholder; the linux uinput implementation +// uses Linux-only ioctls (UI_DEV_CREATE etc.) and is not portable. +type UInputInjector struct { + StubInputInjector +} + +// NewUInputInjector always returns an error on freebsd so callers fall back +// to a stub or platform-appropriate injector. +func NewUInputInjector(_, _ int) (*UInputInjector, error) { + return nil, fmt.Errorf("uinput not implemented on freebsd") +} diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index cf3e2505ed7..c9479538a64 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -169,10 +169,6 @@ func (w *WindowsInputInjector) Close() { func (w *WindowsInputInjector) tryEnqueue(cmd inputCmd) { select { case <-w.closed: - return - default: - } - select { case w.ch <- cmd: default: } diff --git a/client/vnc/server/noise_auth_test.go b/client/vnc/server/noise_auth_test.go index 2da2d817b12..711dc5bcfec 100644 --- a/client/vnc/server/noise_auth_test.go +++ b/client/vnc/server/noise_auth_test.go @@ -268,19 +268,20 @@ func TestNoise_TruncatedMsg1_ClosesConnection(t *testing.T) { conn, err := net.Dial("tcp", addr.String()) require.NoError(t, err) + defer conn.Close() writeHeaderPrefix(t, conn, ModeAttach) _, err = conn.Write([]byte("NBV3")) require.NoError(t, err) _, err = conn.Write(make([]byte, 8)) require.NoError(t, err) - require.NoError(t, conn.Close()) + require.NoError(t, conn.(*net.TCPConn).CloseWrite()) - // Re-dial just to confirm the listener is alive (the previous - // connection terminated server-side without affecting the listener). - probe, err := net.Dial("tcp", addr.String()) - require.NoError(t, err) - require.NoError(t, probe.Close()) + require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 64) + n, err := conn.Read(buf) + require.Equal(t, 0, n, "server must not emit RFB bytes after a truncated handshake") + require.ErrorIs(t, err, io.EOF, "server must close the connection on truncated msg1") } // TestNoise_AuthEnabled_NoHandshake_Rejected proves that with auth on, diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index e6ced7ca10d..60f03b21287 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -58,22 +58,19 @@ func NewSessionKey() (string, []byte, error) { return id, kp.Public, nil } -// lookupSessionKey returns the keypair for id, or false if unknown. -func lookupSessionKey(id string) (noise.DHKey, bool) { +// consumeSessionKey atomically retrieves and removes the keypair for id. +// A session handle is single-use; combining lookup and delete under one +// critical section prevents concurrent callers from observing the same key. +func consumeSessionKey(id string) (noise.DHKey, bool) { sessionKeyStore.mu.Lock() defer sessionKeyStore.mu.Unlock() kp, ok := sessionKeyStore.keys[id] + if ok { + delete(sessionKeyStore.keys, id) + } return kp, ok } -// dropSessionKey removes the keypair for id. Called after the VNC -// connection closes (or after a connect attempt fails terminally). -func dropSessionKey(id string) { - sessionKeyStore.mu.Lock() - delete(sessionKeyStore.keys, id) - sessionKeyStore.mu.Unlock() -} - const ( vncProxyHost = "vnc.proxy.local" vncProxyScheme = "ws" @@ -190,13 +187,10 @@ func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { height: height, } if req.KeySessionID != "" { - kp, ok := lookupSessionKey(req.KeySessionID) + kp, ok := consumeSessionKey(req.KeySessionID) if !ok { return rejectedPromise("unknown VNC session id") } - // A session handle is single-use; drop it before the destination - // holds the private bytes so a leaked handle can't be replayed. - dropSessionKey(req.KeySessionID) dest.sessionPriv = kp.Private dest.sessionPub = kp.Public pub, err := decodePeerPubKey(req.PeerPublicKey) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 0da2db5de65..60263db4ec1 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -2,6 +2,7 @@ package peers import ( "context" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -518,6 +519,15 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) policy.Rules[0].AuthorizedUser = userAuth.UserId } if protocol == types.PolicyRuleProtocolNetbirdVNC && req.SessionPubKey != nil { + pub, err := base64.StdEncoding.DecodeString(*req.SessionPubKey) + if err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "session_pub_key is not valid base64: %v", err), w) + return + } + if len(pub) != 32 { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "session_pub_key must decode to 32 bytes, got %d", len(pub)), w) + return + } policy.Rules[0].SessionPubKey = *req.SessionPubKey } diff --git a/management/server/types/policyrule.go b/management/server/types/policyrule.go index ceb58dd0f68..054a08266a7 100644 --- a/management/server/types/policyrule.go +++ b/management/server/types/policyrule.go @@ -89,10 +89,10 @@ type PolicyRule struct { // AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh AuthorizedUser string - // SessionPubKey is the base64 Ed25519 public key the AuthorizedUser - // will sign session-binding challenges with. Set together with - // AuthorizedUser when the rule was created via temporary-access for - // a VNC scope; empty otherwise. + // SessionPubKey is the base64 X25519 public key used with Noise_IK to + // bind a VNC session to the AuthorizedUser. Set together with + // AuthorizedUser when the rule was created via temporary-access for a + // VNC scope; empty otherwise. SessionPubKey string } diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 049a1b26dd1..4a6e1d7d3b7 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -1008,7 +1008,7 @@ components: type: string example: "tcp/80" session_pub_key: - description: Ephemeral Ed25519 public key the requester will sign session-binding challenges with. Required for VNC rules; ignored for SSH and L4. + description: Ephemeral base64-encoded X25519 public key used with Noise_IK to bind the VNC session. Required for VNC rules; ignored for SSH and L4. type: string example: "n0r3pL4c3h0ld3rK3y==" required: From 1cc5967198cdb85d6943008909011cc329d4473f Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 22 May 2026 11:35:16 +0200 Subject: [PATCH 072/151] Address follow-up CodeRabbit VNC findings --- client/vnc/server/server.go | 4 ++-- management/server/http/handlers/peers/peers_handler.go | 6 +++++- management/server/types/policy_authorized_users.go | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 09112ce5700..ba3c4b53969 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -871,8 +871,8 @@ func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) // public key learned from the handshake. Any handshake failure is fatal // (fail closed). func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte, bool, error) { - peek, err := br.Peek(len(vncIdentityMagic)) - if err != nil || !bytes.Equal(peek, vncIdentityMagic) { + peek, _ := br.Peek(len(vncIdentityMagic)) + if !bytes.Equal(peek, vncIdentityMagic) { return nil, false, nil } if _, err := br.Discard(len(vncIdentityMagic)); err != nil { diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 60263db4ec1..e6f218f0df9 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -518,7 +518,11 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) if protocol == types.PolicyRuleProtocolNetbirdSSH || protocol == types.PolicyRuleProtocolNetbirdVNC { policy.Rules[0].AuthorizedUser = userAuth.UserId } - if protocol == types.PolicyRuleProtocolNetbirdVNC && req.SessionPubKey != nil { + if protocol == types.PolicyRuleProtocolNetbirdVNC { + if req.SessionPubKey == nil || *req.SessionPubKey == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "session_pub_key is required for VNC temporary access"), w) + return + } pub, err := base64.StdEncoding.DecodeString(*req.SessionPubKey) if err != nil { util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "session_pub_key is not valid base64: %v", err), w) diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index 16337590f4b..cd5500bad73 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -81,7 +81,7 @@ func (cb ruleAuthCallbacks) handleVNCRule(rule *PolicyRule, peerInSources, peerI return } cb.collectVNCUsers(rule, state.vncAuthorizedUsers) - if rule.SessionPubKey != "" && rule.AuthorizedUser != "" { + if peerInDestinations && rule.SessionPubKey != "" && rule.AuthorizedUser != "" { state.vncSessionPubKeys = append(state.vncSessionPubKeys, VNCSessionPubKey{ PubKey: rule.SessionPubKey, UserID: rule.AuthorizedUser, From 0f03c612d10981f166423487a536cf945b6852a7 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 22 May 2026 12:01:18 +0200 Subject: [PATCH 073/151] Lower CreateTemporaryAccess complexity and emit VncAuth for session pubkeys --- .../internals/shared/grpc/conversion.go | 8 +++-- .../http/handlers/peers/peers_handler.go | 31 ++++++++++++------- shared/management/http/api/types.gen.go | 2 +- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index a33b75d2c44..1ecb7306bad 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -183,8 +183,12 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} } - if networkMap.VNCAuthorizedUsers != nil { - hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.VNCAuthorizedUsers) + if networkMap.VNCAuthorizedUsers != nil || len(networkMap.VNCSessionPubKeys) > 0 { + var hashedUsers [][]byte + var machineUsers map[string]*proto.MachineUserIndexes + if networkMap.VNCAuthorizedUsers != nil { + hashedUsers, machineUsers = buildAuthorizedUsersProto(ctx, networkMap.VNCAuthorizedUsers) + } response.NetworkMap.VncAuth = &proto.VNCAuth{ AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index e6f218f0df9..45ec2556c67 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -519,20 +519,12 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) policy.Rules[0].AuthorizedUser = userAuth.UserId } if protocol == types.PolicyRuleProtocolNetbirdVNC { - if req.SessionPubKey == nil || *req.SessionPubKey == "" { - util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "session_pub_key is required for VNC temporary access"), w) - return - } - pub, err := base64.StdEncoding.DecodeString(*req.SessionPubKey) + pubKey, err := validateVNCSessionPubKey(req.SessionPubKey) if err != nil { - util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "session_pub_key is not valid base64: %v", err), w) + util.WriteError(r.Context(), err, w) return } - if len(pub) != 32 { - util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "session_pub_key must decode to 32 bytes, got %d", len(pub)), w) - return - } - policy.Rules[0].SessionPubKey = *req.SessionPubKey + policy.Rules[0].SessionPubKey = pubKey } _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true) @@ -552,6 +544,23 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) util.WriteJSONObject(r.Context(), w, resp) } +// validateVNCSessionPubKey ensures the request carries a base64-encoded +// 32-byte X25519 public key for VNC temporary access. Returns the original +// base64 string on success. +func validateVNCSessionPubKey(raw *string) (string, error) { + if raw == nil || *raw == "" { + return "", status.Errorf(status.InvalidArgument, "session_pub_key is required for VNC temporary access") + } + pub, err := base64.StdEncoding.DecodeString(*raw) + if err != nil { + return "", status.Errorf(status.InvalidArgument, "session_pub_key is not valid base64: %v", err) + } + if len(pub) != 32 { + return "", status.Errorf(status.InvalidArgument, "session_pub_key must decode to 32 bytes, got %d", len(pub)) + } + return *raw, nil +} + func toAccessiblePeers(netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer { accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers)) for _, p := range netMap.Peers { diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index e7447a7c681..5832be29b3b 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -3391,7 +3391,7 @@ type PeerTemporaryAccessRequest struct { // Rules List of temporary access rules Rules []string `json:"rules"` - // SessionPubKey Ephemeral Ed25519 public key the requester will sign session-binding challenges with. Required for VNC rules; ignored for SSH and L4. + // SessionPubKey Ephemeral base64-encoded X25519 public key used with Noise_IK to bind the VNC session. Required for VNC rules; ignored for SSH and L4. SessionPubKey *string `json:"session_pub_key,omitempty"` // WgPubKey Peer's WireGuard public key From 030c57150f5743be3fb462a3558dcd3ca42f5b5e Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 22 May 2026 12:06:52 +0200 Subject: [PATCH 074/151] Signal Zlib encode failure and fall back to Raw --- client/vnc/server/rfb.go | 8 ++++---- client/vnc/server/session_encode.go | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index af6ab3114e5..f889676420e 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -266,7 +266,7 @@ func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte // encoding. The zlib stream is continuous for the entire VNC session: the // client keeps a single inflate context and reuses it across rects. The // returned buffer includes the 4-byte FramebufferUpdate header. -func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zlibState) []byte { +func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zlibState) ([]byte, bool) { zw, zbuf := z.w, z.buf zbuf.Reset() @@ -280,12 +280,12 @@ func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zl for row := 0; row < h; row++ { if _, err := zw.Write(scratch[row*rowBytes : (row+1)*rowBytes]); err != nil { log.Debugf("zlib write row %d: %v", row, err) - return nil + return nil, false } } if err := zw.Flush(); err != nil { log.Debugf("zlib flush: %v", err) - return nil + return nil, false } compressed := zbuf.Bytes() @@ -299,7 +299,7 @@ func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zl binary.BigEndian.PutUint32(buf[12:16], uint32(encZlib)) binary.BigEndian.PutUint32(buf[16:20], uint32(len(compressed))) copy(buf[20:], compressed) - return buf + return buf, true } // encodeHextileSolidRect emits a Hextile-encoded rectangle whose every diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 9c8993fb6c3..38fc058bade 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -450,11 +450,18 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { rectBuf = encodeTightRect(img, pf, 0, 0, w, h, tight) case useZlib && zlib != nil: // encodeZlibRect bakes in its own FBU header; reuse it for the - // single-rect path when there is no cursor to prepend. - if cursorRect == nil { - return s.writeFramed(encodeZlibRect(img, pf, 0, 0, w, h, zlib)) + // single-rect path when there is no cursor to prepend. Fall back + // to Raw if the compressor errors out. + if zb, ok := encodeZlibRect(img, pf, 0, 0, w, h, zlib); ok { + if cursorRect == nil { + return s.writeFramed(zb) + } + rectBuf = zb[4:] + } else if cursorRect == nil { + return s.writeFramed(encodeRawRect(img, pf, 0, 0, w, h)) + } else { + rectBuf = encodeRawRect(img, pf, 0, 0, w, h)[4:] } - rectBuf = encodeZlibRect(img, pf, 0, 0, w, h, zlib)[4:] default: if cursorRect == nil { return s.writeFramed(encodeRawRect(img, pf, 0, 0, w, h)) @@ -558,7 +565,9 @@ func (s *session) encodeTile(img *image.RGBA, x, y, w, h int) []byte { return encodeTightRect(img, pf, x, y, w, h, tight) } if useZlib && zlib != nil { - return encodeZlibRect(img, pf, x, y, w, h, zlib)[4:] + if zb, ok := encodeZlibRect(img, pf, x, y, w, h, zlib); ok { + return zb[4:] + } } return encodeRawRect(img, pf, x, y, w, h)[4:] } From 97b7b010f55a019dc391b1948cea28513c12b43f Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 22 May 2026 12:41:24 +0200 Subject: [PATCH 075/151] Fold init-only VNC and SSH setters into Config-struct constructors --- client/cmd/vnc_agent.go | 9 ++- client/internal/engine_ssh.go | 14 ++-- client/internal/engine_vnc.go | 22 +++--- client/ssh/server/server.go | 29 ++++---- client/vnc/server/noise_auth_test.go | 12 ++-- client/vnc/server/server.go | 102 ++++++++++++--------------- client/vnc/server/server_test.go | 43 +++++++---- 7 files changed, 117 insertions(+), 114 deletions(-) diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go index 2ca82c0e78b..300fcdf21cc 100644 --- a/client/cmd/vnc_agent.go +++ b/client/cmd/vnc_agent.go @@ -54,9 +54,12 @@ var vncAgentCmd = &cobra.Command{ // The per-user agent listens only on loopback and is gated by an // agent token shared with the daemon, so no X25519 identity key // is needed; auth is disabled at the RFB layer. - srv := vncserver.New(capturer, injector, nil) - srv.SetDisableAuth(true) - srv.SetAgentToken(token) + srv := vncserver.New(vncserver.Config{ + Capturer: capturer, + Injector: injector, + DisableAuth: true, + AgentTokenHex: token, + }) addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), vncAgentPort) loopback := netip.PrefixFrom(netip.AddrFrom4([4]byte{127, 0, 0, 0}), 8) diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index 53d2c112268..e02bcba369b 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -237,22 +237,18 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { return errors.New("wg interface not initialized") } + wgAddr := e.wgInterface.Address() serverConfig := &sshserver.Config{ - HostKeyPEM: e.config.SSHKey, - JWT: jwtConfig, + HostKeyPEM: e.config.SSHKey, + JWT: jwtConfig, + NetstackNet: e.wgInterface.GetNet(), + NetworkValidation: wgAddr, } server := sshserver.New(serverConfig) - wgAddr := e.wgInterface.Address() - server.SetNetworkValidation(wgAddr) - netbirdIP := wgAddr.IP listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort) - if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { - server.SetNetstackNet(netstackNet) - } - e.configureSSHServer(server) if err := server.Start(e.ctx, listenAddr); err != nil { diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index a37e4da87cd..47b54db9851 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -99,9 +99,9 @@ func (e *Engine) startVNCServer() error { netbirdIP := e.wgInterface.Address().IP - srv := vncserver.New(capturer, injector, e.config.WgPrivateKey[:]) + var sessionRecorder func(vncserver.SessionTick) if e.clientMetrics != nil { - srv.SetSessionRecorder(func(t vncserver.SessionTick) { + sessionRecorder = func(t vncserver.SessionTick) { e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{ Period: t.Period, BytesOut: t.BytesOut, @@ -112,16 +112,20 @@ func (e *Engine) startVNCServer() error { MaxWriteBytes: t.MaxWriteBytes, WriteNanos: t.WriteNanos, }) - }) + } } - if vncNeedsServiceMode() { + serviceMode := vncNeedsServiceMode() + if serviceMode { log.Info("VNC: running in Session 0, enabling service mode (agent proxy)") - srv.SetServiceMode(true) - } - - if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { - srv.SetNetstackNet(netstackNet) } + srv := vncserver.New(vncserver.Config{ + Capturer: capturer, + Injector: injector, + IdentityKey: e.config.WgPrivateKey[:], + ServiceMode: serviceMode, + SessionRecorder: sessionRecorder, + NetstackNet: e.wgInterface.GetNet(), + }) listenAddr := netip.AddrPortFrom(netbirdIP, vncInternalPort) network := e.wgInterface.Address().Network diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 6735e0f3bc0..3d55de6dc39 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -197,6 +197,14 @@ type Config struct { // HostKey is the SSH server host key in PEM format HostKeyPEM []byte + + // NetstackNet, when non-nil, makes the SSH server listen via the + // supplied userspace network stack instead of an OS socket. + NetstackNet *netstack.Net + + // NetworkValidation, when non-zero, restricts inbound connections to + // peers inside the NetBird overlay defined by this WireGuard address. + NetworkValidation wgaddr.Address } // SessionInfo contains information about an active SSH session @@ -208,12 +216,15 @@ type SessionInfo struct { PortForwards []string } -// New creates an SSH server instance with the provided host key and optional JWT configuration -// If jwtConfig is nil, JWT authentication is disabled +// New creates an SSH server instance from the supplied Config. Fields are +// read once at construction; mutating Config afterwards has no effect. +// JWT == nil disables JWT authentication. func New(config *Config) *Server { s := &Server{ mu: sync.RWMutex{}, hostKeyPEM: config.HostKeyPEM, + netstackNet: config.NetstackNet, + wgAddress: config.NetworkValidation, sessions: make(map[sessionKey]*sessionState), pendingAuthJWT: make(map[authKey]string), remoteForwardListeners: make(map[forwardKey]net.Listener), @@ -434,20 +445,6 @@ func (s *Server) buildSessionInfo(state *sessionState) SessionInfo { return info } -// SetNetstackNet sets the netstack network for userspace networking -func (s *Server) SetNetstackNet(net *netstack.Net) { - s.mu.Lock() - defer s.mu.Unlock() - s.netstackNet = net -} - -// SetNetworkValidation configures network-based connection filtering -func (s *Server) SetNetworkValidation(addr wgaddr.Address) { - s.mu.Lock() - defer s.mu.Unlock() - s.wgAddress = addr -} - // UpdateSSHAuth updates the SSH fine-grained access control configuration // This should be called when network map updates include new SSH auth configuration func (s *Server) UpdateSSHAuth(config *sshauth.Config) { diff --git a/client/vnc/server/noise_auth_test.go b/client/vnc/server/noise_auth_test.go index 711dc5bcfec..34ec054904c 100644 --- a/client/vnc/server/noise_auth_test.go +++ b/client/vnc/server/noise_auth_test.go @@ -28,8 +28,11 @@ func noiseTestServer(t *testing.T) (net.Addr, *Server, []byte) { kp, err := noise.DH25519.GenerateKeypair(nil) require.NoError(t, err) - srv := New(&testCapturer{}, &StubInputInjector{}, kp.Private) - srv.SetDisableAuth(false) + srv := New(Config{ + Capturer: &testCapturer{}, + Injector: &StubInputInjector{}, + IdentityKey: kp.Private, + }) addr := netip.MustParseAddrPort("127.0.0.1:0") network := netip.MustParsePrefix("127.0.0.0/8") @@ -338,8 +341,7 @@ func TestNoise_RevokedKey_RejectedAfterAuthUpdate(t *testing.T) { // without a static private key still rejects authenticated connections // fail-closed; it must not silently accept the client. func TestNoise_NoIdentityKey_FailsClosed(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, nil) - srv.SetDisableAuth(false) + srv := New(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}}) addr := netip.MustParseAddrPort("127.0.0.1:0") network := netip.MustParsePrefix("127.0.0.0/8") require.NoError(t, srv.Start(t.Context(), addr, network)) @@ -384,7 +386,7 @@ func TestNoise_DerivedIdentityPublicMatchesPrivate(t *testing.T) { for i := range priv { priv[i] = byte(i + 1) } - srv := New(&testCapturer{}, &StubInputInjector{}, priv) + srv := New(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}, IdentityKey: priv}) expected, err := curve25519.X25519(priv, curve25519.Basepoint) require.NoError(t, err) diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index ba3c4b53969..87f31f853a6 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -254,30 +254,57 @@ type virtualSessionManager interface { StopAll() } -// New creates a VNC server. identityKey is the 32-byte X25519 private -// key used by the daemon in the Noise_IK handshake; nil disables auth. -// The protocol-level VNC password scheme is not supported. -func New(capturer ScreenCapturer, injector InputInjector, identityKey []byte) *Server { +// Config bundles the values the VNC server needs at construction time. +// Fields are read once by New; mutating them afterwards has no effect. +// Optional fields are nil/zero when unused. The hex-encoded AgentTokenHex +// is decoded internally and an invalid value is logged and treated as +// empty, matching the legacy SetAgentToken behavior. +type Config struct { + Capturer ScreenCapturer + Injector InputInjector + IdentityKey []byte + ServiceMode bool + SessionRecorder func(SessionTick) + DisableAuth bool + AgentTokenHex string + NetstackNet *netstack.Net +} + +// New creates a VNC server from the provided Config. IdentityKey is the +// 32-byte X25519 private key used in the Noise_IK handshake; nil disables +// auth. The protocol-level VNC password scheme is not supported. +func New(cfg Config) *Server { s := &Server{ - capturer: capturer, - injector: injector, - identityKey: identityKey, - authorizer: sshauth.NewAuthorizer(), - log: log.WithField("component", "vnc-server"), - sessions: make(map[uint64]ActiveSessionInfo), - sessionConns: make(map[uint64]net.Conn), - acceptedConns: make(map[net.Conn]struct{}), - connAuth: make(map[net.Conn]connAuthInfo), - connSem: make(chan struct{}, maxConcurrentVNCConns), - } - if len(identityKey) == 32 { - pub, err := curve25519.X25519(identityKey, curve25519.Basepoint) + capturer: cfg.Capturer, + injector: cfg.Injector, + identityKey: cfg.IdentityKey, + serviceMode: cfg.ServiceMode, + sessionRecorder: cfg.SessionRecorder, + disableAuth: cfg.DisableAuth, + netstackNet: cfg.NetstackNet, + authorizer: sshauth.NewAuthorizer(), + log: log.WithField("component", "vnc-server"), + sessions: make(map[uint64]ActiveSessionInfo), + sessionConns: make(map[uint64]net.Conn), + acceptedConns: make(map[net.Conn]struct{}), + connAuth: make(map[net.Conn]connAuthInfo), + connSem: make(chan struct{}, maxConcurrentVNCConns), + } + if len(cfg.IdentityKey) == 32 { + pub, err := curve25519.X25519(cfg.IdentityKey, curve25519.Basepoint) if err == nil { s.identityPublic = pub } else { s.log.Warnf("derive identity public key: %v", err) } } + if cfg.AgentTokenHex != "" { + if b, err := hex.DecodeString(cfg.AgentTokenHex); err == nil { + s.agentToken = b + } else { + s.log.Warnf("invalid agent token: %v", err) + } + } return s } @@ -407,47 +434,6 @@ func (s *Server) revokeUnauthorizedSessions() { } } -// SetServiceMode enables proxy-to-agent mode for Windows service operation. -func (s *Server) SetServiceMode(enabled bool) { - s.serviceMode = enabled -} - -// SetSessionRecorder installs a callback that receives a SessionTick -// each sessionTickInterval during a VNC session and one final tick on -// session close. Pass nil to disable. Empty ticks (no wire activity) -// are skipped. -func (s *Server) SetSessionRecorder(recorder func(SessionTick)) { - s.sessionRecorder = recorder -} - -// SetDisableAuth disables authentication entirely. -func (s *Server) SetDisableAuth(disable bool) { - s.disableAuth = disable -} - -// SetAgentToken sets a hex-encoded token that must be presented by incoming -// connections before any VNC data. Used in agent mode to verify that only the -// trusted service process connects. -func (s *Server) SetAgentToken(hexToken string) { - if hexToken == "" { - return - } - b, err := hex.DecodeString(hexToken) - if err != nil { - s.log.Warnf("invalid agent token: %v", err) - return - } - s.agentToken = b -} - -// SetNetstackNet sets the netstack network for userspace-only listening. -// When set, the VNC server listens via netstack instead of a real OS socket. -func (s *Server) SetNetstackNet(n *netstack.Net) { - s.mu.Lock() - defer s.mu.Unlock() - s.netstackNet = n -} - // UpdateVNCAuth updates the fine-grained authorization configuration and // closes any live session whose identity no longer authenticates under // the new policy. Revocation is event-driven: there is no periodic diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index ea37673d192..96a60175b6c 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -28,8 +28,11 @@ func (t *testCapturer) Capture() (*image.RGBA, error) { func startTestServer(t *testing.T, disableAuth bool) (net.Addr, *Server) { t.Helper() - srv := New(&testCapturer{}, &StubInputInjector{}, nil) - srv.SetDisableAuth(disableAuth) + srv := New(Config{ + Capturer: &testCapturer{}, + Injector: &StubInputInjector{}, + DisableAuth: disableAuth, + }) addr := netip.MustParseAddrPort("127.0.0.1:0") network := netip.MustParsePrefix("127.0.0.0/8") @@ -112,8 +115,11 @@ func TestAuthDisabled_AllowsConnection(t *testing.T) { // server must close immediately and the client must see EOF before any RFB // version greeting is written. func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, nil) - srv.SetDisableAuth(true) + srv := New(Config{ + Capturer: &testCapturer{}, + Injector: &StubInputInjector{}, + DisableAuth: true, + }) addr := netip.MustParseAddrPort("127.0.0.1:0") // Tight overlay that excludes 127.0.0.0/8 and a non-loopback local IP, so // the loopback short-circuit in isAllowedSource doesn't apply. @@ -193,7 +199,7 @@ func TestIsAllowedSource(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, nil) + srv := New(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}}) srv.localAddr = tc.localAddr srv.network = tc.network assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote)) @@ -202,7 +208,7 @@ func TestIsAllowedSource(t *testing.T) { } func TestStart_InvalidNetworkRejected(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, nil) + srv := New(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}}) addr := netip.MustParseAddrPort("127.0.0.1:0") err := srv.Start(t.Context(), addr, netip.Prefix{}) require.Error(t, err, "Start must refuse an invalid overlay prefix") @@ -210,9 +216,12 @@ func TestStart_InvalidNetworkRejected(t *testing.T) { } func TestAgentToken_MismatchClosesConnection(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, nil) - srv.SetDisableAuth(true) - srv.SetAgentToken("deadbeefcafebabe") + srv := New(Config{ + Capturer: &testCapturer{}, + Injector: &StubInputInjector{}, + DisableAuth: true, + AgentTokenHex: "deadbeefcafebabe", + }) addr := netip.MustParseAddrPort("127.0.0.1:0") network := netip.MustParsePrefix("127.0.0.0/8") @@ -238,10 +247,13 @@ func TestAgentToken_MismatchClosesConnection(t *testing.T) { } func TestAgentToken_MatchAllowsHandshake(t *testing.T) { - srv := New(&testCapturer{}, &StubInputInjector{}, nil) - srv.SetDisableAuth(true) const tokenHex = "deadbeefcafebabe" - srv.SetAgentToken(tokenHex) + srv := New(Config{ + Capturer: &testCapturer{}, + Injector: &StubInputInjector{}, + DisableAuth: true, + AgentTokenHex: tokenHex, + }) token, err := hex.DecodeString(tokenHex) require.NoError(t, err) @@ -275,8 +287,11 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) { func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) { // Default platformSessionManager() on non-Linux returns nil, so ModeSession // must be rejected with the UNSUPPORTED reason rather than crashing. - srv := New(&testCapturer{}, &StubInputInjector{}, nil) - srv.SetDisableAuth(true) + srv := New(Config{ + Capturer: &testCapturer{}, + Injector: &StubInputInjector{}, + DisableAuth: true, + }) addr := netip.MustParseAddrPort("127.0.0.1:0") network := netip.MustParsePrefix("127.0.0.0/8") From c29ef638f46003a78c11d0c6b12a16d738f72b78 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 22 May 2026 15:32:35 +0200 Subject: [PATCH 076/151] Switch VNC daemon-to-agent IPC to Unix sockets and audit-log every connection --- client/cmd/vnc_agent.go | 40 ++++---- client/internal/engine_vnc.go | 2 +- client/vnc/server/agent_darwin.go | 81 +++++++++------- client/vnc/server/agent_ipc.go | 124 ++++++++++++++++++------ client/vnc/server/agent_windows.go | 142 +++++++--------------------- client/vnc/server/server.go | 86 ++++++++++------- client/vnc/server/server_darwin.go | 72 ++------------ client/vnc/server/server_test.go | 8 +- client/vnc/server/server_windows.go | 54 +---------- 9 files changed, 270 insertions(+), 339 deletions(-) diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go index 300fcdf21cc..742b498ba41 100644 --- a/client/cmd/vnc_agent.go +++ b/client/cmd/vnc_agent.go @@ -4,6 +4,7 @@ package cmd import ( "fmt" + "net" "net/netip" "os" @@ -13,16 +14,16 @@ import ( vncserver "github.com/netbirdio/netbird/client/vnc/server" ) -var vncAgentPort uint16 +var vncAgentSocket string func init() { - vncAgentCmd.Flags().Uint16Var(&vncAgentPort, "port", 15900, "Port for the VNC agent to listen on") + vncAgentCmd.Flags().StringVar(&vncAgentSocket, "socket", "", "Unix-domain socket path the agent listens on (required)") rootCmd.AddCommand(vncAgentCmd) } // vncAgentCmd runs a VNC server inside the user's interactive session, -// listening on localhost. The NetBird service spawns it: on Windows via -// CreateProcessAsUser into the console session, on macOS via +// listening on a Unix-domain socket. The NetBird service spawns it: on +// Windows via CreateProcessAsUser into the console session, on macOS via // launchctl asuser into the Aqua session. var vncAgentCmd = &cobra.Command{ Use: "vnc-agent", @@ -33,40 +34,47 @@ var vncAgentCmd = &cobra.Command{ log.SetFormatter(&log.JSONFormatter{}) log.SetOutput(os.Stderr) - log.Infof("VNC agent starting on 127.0.0.1:%d", vncAgentPort) + if vncAgentSocket == "" { + return fmt.Errorf("--socket is required") + } token := os.Getenv("NB_VNC_AGENT_TOKEN") if token == "" { return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service") } - // Drop the token from our process environment so any child the - // agent spawns does not inherit it, and casual debugging tools - // that dump /proc//environ (or the Windows equivalent) on a - // running agent don't surface the loopback shared secret. + // Purge the token from env so it doesn't leak via /proc//environ. if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil { log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err) } + if err := os.Remove(vncAgentSocket); err != nil && !os.IsNotExist(err) { + log.Debugf("remove stale socket %s: %v", vncAgentSocket, err) + } + ln, err := net.Listen("unix", vncAgentSocket) + if err != nil { + return fmt.Errorf("listen on %s: %w", vncAgentSocket, err) + } + if err := os.Chmod(vncAgentSocket, 0o600); err != nil { + log.Debugf("chmod %s: %v", vncAgentSocket, err) + } + capturer, injector, err := newAgentResources() if err != nil { + _ = ln.Close() return err } - // The per-user agent listens only on loopback and is gated by an - // agent token shared with the daemon, so no X25519 identity key - // is needed; auth is disabled at the RFB layer. srv := vncserver.New(vncserver.Config{ Capturer: capturer, Injector: injector, DisableAuth: true, AgentTokenHex: token, + Listener: ln, }) - addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), vncAgentPort) - loopback := netip.PrefixFrom(netip.AddrFrom4([4]byte{127, 0, 0, 0}), 8) - if err := srv.Start(cmd.Context(), addr, loopback); err != nil { + if err := srv.Start(cmd.Context(), netip.AddrPort{}, netip.Prefix{}); err != nil { return fmt.Errorf("start vnc server: %w", err) } - log.Infof("vnc-agent listening on 127.0.0.1:%d, ready", vncAgentPort) + log.Infof("vnc-agent listening on %s, ready", vncAgentSocket) <-cmd.Context().Done() log.Info("vnc-agent context cancelled, shutting down") diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 47b54db9851..dfb63345c0b 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -116,7 +116,7 @@ func (e *Engine) startVNCServer() error { } serviceMode := vncNeedsServiceMode() if serviceMode { - log.Info("VNC: running in Session 0, enabling service mode (agent proxy)") + log.Info("VNC: running as system service, enabling service mode (per-session agent proxy)") } srv := vncserver.New(vncserver.Config{ Capturer: capturer, diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index da8083bcf64..3da2c5aeee8 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -30,19 +30,25 @@ import ( // asuser + listen-readiness wait, ~hundreds of milliseconds in practice. // That cost only repeats on user switch. type darwinAgentManager struct { - mu sync.Mutex - authToken string - port uint16 - uid uint32 - running bool + mu sync.Mutex + authToken string + socketPath string + uid uint32 + running bool } func newDarwinAgentManager(ctx context.Context) *darwinAgentManager { - m := &darwinAgentManager{port: agentPort} + m := &darwinAgentManager{} go m.watchConsoleUser(ctx) return m } +// agentSocketPathFmt parameterizes the agent's loopback Unix-socket path +// by the console uid: /tmp is writable in the launchctl-asuser context +// and predictable to the daemon. The agent chmods the file 0600 after +// bind so only its uid (plus root) can dial. +const agentSocketPathFmt = "/tmp/netbird-vnc-%d.sock" + // watchConsoleUser kills the cached agent whenever the console user // changes (logout, fast user switch, login window). Without it the daemon // keeps proxying to an agent whose TCC grant and WindowServer access @@ -80,41 +86,45 @@ func (m *darwinAgentManager) watchConsoleUser(ctx context.Context) { } } -// ensure returns a token good for proxyToAgent. It spawns or respawns the -// per-user agent process as needed and waits until it is listening on the -// loopback port. Each ensure call is serialized so concurrent VNC clients -// share the same agent. -func (m *darwinAgentManager) ensure(ctx context.Context) (string, error) { +// Resolve spawns or respawns the per-user agent process as needed and +// returns its Unix-socket path and shared token. Each call is serialized +// so concurrent VNC clients share the same agent. +func (m *darwinAgentManager) Resolve(ctx context.Context) (string, string, error) { consoleUID, err := consoleUserID() if err != nil { - return "", fmt.Errorf("no console user: %w", err) + return "", "", fmt.Errorf("no console user: %w", err) } m.mu.Lock() defer m.mu.Unlock() if m.running && m.uid == consoleUID && vncAgentRunning() { - return m.authToken, nil + return m.socketPath, m.authToken, nil } m.killLocked() - // Reap any stray external vnc-agent so the new token is the only one - // the freshly spawned agent will accept on the loopback port. + // Reap stray agents so the new token is the only accepted one. killAllVNCAgents() + socketPath := fmt.Sprintf(agentSocketPathFmt, consoleUID) + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debugf("clear stale agent socket %s: %v", socketPath, err) + } + token, err := generateAuthToken() if err != nil { - return "", fmt.Errorf("generate agent auth token: %w", err) + return "", "", fmt.Errorf("generate agent auth token: %w", err) } - if err := spawnAgentForUser(consoleUID, m.port, token); err != nil { - return "", err + if err := spawnAgentForUser(consoleUID, socketPath, token); err != nil { + return "", "", err } - if err := waitForAgent(ctx, m.port, 5*time.Second); err != nil { + if err := waitForAgent(ctx, socketPath, 5*time.Second); err != nil { killAllVNCAgents() - return "", fmt.Errorf("agent did not start listening: %w", err) + return "", "", fmt.Errorf("agent did not start listening: %w", err) } m.authToken = token + m.socketPath = socketPath m.uid = consoleUID m.running = true - log.Infof("spawned VNC agent for console uid=%d on port %d", consoleUID, m.port) - return token, nil + log.Infof("spawned VNC agent for console uid=%d on %s", consoleUID, socketPath) + return socketPath, token, nil } // stop terminates the spawned agent, if any. Intended for daemon shutdown. @@ -129,16 +139,17 @@ func (m *darwinAgentManager) killLocked() { return } killAllVNCAgents() + if m.socketPath != "" { + if err := os.Remove(m.socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debugf("remove agent socket %s: %v", m.socketPath, err) + } + } m.running = false m.authToken = "" + m.socketPath = "" m.uid = 0 } -// errNoConsoleUser is the sentinel callers use to recognise the -// "login window showing, no user signed in" state and surface it as a -// distinct condition to the VNC client. -var errNoConsoleUser = errors.New("no user logged into console") - // consoleUserID returns the uid of the user currently sitting at the // console (the one whose Aqua session is active). Returns // errNoConsoleUser when nobody is logged in: at the login window @@ -164,14 +175,14 @@ func consoleUserID() (uint32, error) { // WindowServer. The agent's stderr is relogged into the daemon log so // startup failures are not silently lost when the readiness check times // out. -func spawnAgentForUser(uid uint32, port uint16, token string) error { +func spawnAgentForUser(uid uint32, socketPath, token string) error { exe, err := os.Executable() if err != nil { return fmt.Errorf("resolve own executable: %w", err) } cmd := exec.Command( "/bin/launchctl", "asuser", strconv.FormatUint(uint64(uid), 10), - exe, vncAgentSubcommand, "--port", strconv.FormatUint(uint64(port), 10), + exe, vncAgentSubcommand, "--socket", socketPath, ) cmd.Env = append(os.Environ(), agentTokenEnvVar+"="+token) stderr, err := cmd.StderrPipe() @@ -189,23 +200,25 @@ func spawnAgentForUser(uid uint32, port uint16, token string) error { return nil } -// waitForAgent dials the loopback port until the agent answers. Used to +// waitForAgent dials the agent's Unix socket until it answers. Used to // gate proxy attempts until the spawned process has finished its Start. -func waitForAgent(ctx context.Context, port uint16, wait time.Duration) error { - addr := fmt.Sprintf("127.0.0.1:%d", port) +func waitForAgent(ctx context.Context, socketPath string, wait time.Duration) error { + var d net.Dialer deadline := time.Now().Add(wait) for time.Now().Before(deadline) { if ctx.Err() != nil { return ctx.Err() } - c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + dialCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) + c, err := d.DialContext(dialCtx, "unix", socketPath) + cancel() if err == nil { _ = c.Close() return nil } time.Sleep(100 * time.Millisecond) } - return fmt.Errorf("timeout dialing %s", addr) + return fmt.Errorf("timeout dialing %s", socketPath) } // vncAgentRunning reports whether any vnc-agent process exists on the diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index 7645201fdc3..a9ef3a77ac7 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -4,6 +4,7 @@ package server import ( "bufio" + "bytes" "context" crand "crypto/rand" "encoding/hex" @@ -17,14 +18,84 @@ import ( log "github.com/sirupsen/logrus" ) -const ( - // agentPort is the TCP loopback port on which a per-session VNC agent - // listens. The daemon dials this port and presents agentToken before - // proxying VNC bytes. The choice of TCP (rather than a Unix socket or - // named pipe) is intentional: it lets the same proxy/handshake code - // run on every platform; the token does the access control. - agentPort uint16 = 15900 +// errNoConsoleUser is the sentinel returned by sessionAgent.Resolve when +// the platform has no interactive user to attach a capture agent to (the +// macOS loginwindow state). Mapped to a distinct RFB reject code so the +// browser can show a meaningful message. +var errNoConsoleUser = errors.New("no user logged into console") + +// sessionAgent abstracts the per-platform manager that spawns and tracks +// the user-session VNC agent. Resolve returns the agent's Unix-socket +// path and shared token, possibly spawning lazily. +type sessionAgent interface { + Resolve(ctx context.Context) (socketPath, token string, err error) +} + +// prefixConn replays already-consumed header bytes ahead of the proxy +// stream by swapping in a different Reader on the same underlying Conn. +type prefixConn struct { + io.Reader + net.Conn +} + +func (p *prefixConn) Read(b []byte) (int, error) { return p.Reader.Read(b) } + +// handleServiceConnection runs the connection-header handshake (source +// check, Noise_IK auth) on conn, resolves the right per-session agent +// via sa, and proxies to it. Every accepted connection emits exactly one +// outcome line on the daemon log. +func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { + start := time.Now() + connLog := s.log.WithField("remote", conn.RemoteAddr().String()) + + if !s.isAllowedSource(conn.RemoteAddr()) { + connLog.Info("VNC connection rejected: source not allowed") + _ = conn.Close() + return + } + + var headerBuf bytes.Buffer + tee := io.TeeReader(conn, &headerBuf) + teeConn := &prefixConn{Reader: tee, Conn: conn} + + header, err := s.readConnectionHeader(teeConn) + if err != nil { + connLog.Infof("VNC connection rejected: header read failed: %v", err) + _ = conn.Close() + return + } + + authedLog, _, ok := s.authorizeSession(conn, header, connLog) + if !ok { + authedLog.Info("VNC connection rejected: auth failed") + return + } + s.registerConnAuth(conn, header) + + socketPath, token, err := sa.Resolve(s.ctx) + if err != nil { + code := RejectCodeCapturerError + if errors.Is(err, errNoConsoleUser) { + code = RejectCodeNoConsoleUser + } + rejectConnection(conn, codeMessage(code, err.Error())) + authedLog.Warnf("VNC connection rejected: agent unavailable: %v", err) + return + } + replayConn := &prefixConn{ + Reader: io.MultiReader(&headerBuf, conn), + Conn: conn, + } + if err := proxyToAgent(s.ctx, replayConn, socketPath, token); err != nil { + rejectConnection(conn, codeMessage(RejectCodeCapturerError, err.Error())) + authedLog.Warnf("VNC connection rejected: agent unreachable: %v", err) + return + } + authedLog.Infof("VNC connection closed (%dms)", time.Since(start).Milliseconds()) +} + +const ( // agentTokenLen is the size of the random per-spawn token in bytes. agentTokenLen = 32 @@ -52,32 +123,30 @@ func generateAuthToken() (string, error) { return hex.EncodeToString(b), nil } -// proxyToAgent dials the per-session agent on TCP loopback, writes the -// raw token bytes, and then copies bytes in both directions until either -// side closes. The token has to land on the wire before any VNC byte so -// the agent's listening Server can apply verifyAgentToken before letting -// real RFB traffic through. -func proxyToAgent(ctx context.Context, client net.Conn, port uint16, authToken string) { - defer client.Close() +// proxyToAgent dials the per-session agent's Unix socket, writes the +// raw token bytes, then copies bytes both ways until either side closes. +// The token must precede any RFB byte so the agent's verifyAgentToken +// can run first. Returns nil once a stream is established; the caller is +// responsible for sending an RFB-level rejection on error so the client +// sees a reason instead of a bare timeout. +func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken string) error { + tokenBytes, err := hex.DecodeString(authToken) + if err != nil || len(tokenBytes) != agentTokenLen { + return fmt.Errorf("invalid auth token (len=%d): %w", len(tokenBytes), err) + } - addr := fmt.Sprintf("127.0.0.1:%d", port) - agentConn, err := dialAgentWithRetry(ctx, addr) + agentConn, err := dialAgentWithRetry(ctx, socketPath) if err != nil { - log.Warnf("proxy cannot reach agent at %s: %v", addr, err) - return + return fmt.Errorf("dial agent at %s: %w", socketPath, err) } - defer agentConn.Close() - tokenBytes, err := hex.DecodeString(authToken) - if err != nil || len(tokenBytes) != agentTokenLen { - log.Warnf("invalid auth token (len=%d): %v", len(tokenBytes), err) - return - } if _, err := agentConn.Write(tokenBytes); err != nil { - log.Warnf("send auth token to agent: %v", err) - return + _ = agentConn.Close() + return fmt.Errorf("send auth token to agent: %w", err) } + defer client.Close() + defer agentConn.Close() log.Debugf("proxy connected to agent, starting bidirectional copy") done := make(chan struct{}, 2) cp := func(label string, dst, src net.Conn) { @@ -88,6 +157,7 @@ func proxyToAgent(ctx context.Context, client net.Conn, port uint16, authToken s go cp("client→agent", agentConn, client) go cp("agent→client", client, agentConn) <-done + return nil } // relogAgentStream reads log lines from the agent's stderr and re-emits @@ -159,7 +229,7 @@ func dialAgentWithRetry(ctx context.Context, addr string) (net.Conn, error) { return nil, lastErr } dialCtx, cancel := context.WithTimeout(ctx, time.Second) - c, err := d.DialContext(dialCtx, "tcp", addr) + c, err := d.DialContext(dialCtx, "unix", addr) cancel() if err == nil { return c, nil diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 0e27212e40e..735ab274d57 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -3,12 +3,12 @@ package server import ( + "context" "encoding/binary" "errors" "fmt" "os" "runtime" - "strings" "sync" "time" "unsafe" @@ -49,7 +49,6 @@ var ( procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW") iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") - procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable") ) // GetCurrentSessionID returns the session ID of the current process. @@ -138,97 +137,6 @@ func getActiveSessionID() uint32 { return getConsoleSessionID() } -// reapOrphanOnPort finds any process listening on 127.0.0.1:port and, if -// it's a netbird vnc-agent left over from a previous service instance, -// terminates it. Verified by image-name match so we never kill an -// unrelated process that happens to use the same port. -func reapOrphanOnPort(port uint16) { - pid := tcpListenerPID(port) - if pid == 0 || pid == uint32(windows.GetCurrentProcessId()) { - return - } - h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid) - if err != nil { - log.Warnf("reap on port %d: open PID=%d: %v", port, pid, err) - return - } - defer func() { _ = windows.CloseHandle(h) }() - if !isOurAgentProcess(h) { - log.Warnf("reap on port %d: PID=%d is not a netbird vnc-agent, leaving it alone", port, pid) - return - } - if err := windows.TerminateProcess(h, 0); err != nil { - log.Warnf("reap on port %d: terminate PID=%d: %v", port, pid, err) - return - } - log.Infof("reaped orphan vnc-agent PID=%d holding port %d", pid, port) -} - -// isOurAgentProcess returns true if the given process handle points at a -// netbird.exe binary at the same path as the current process. We compare -// full paths (case-insensitive on Windows) so co-installed netbird binaries -// from a different install dir or unrelated apps named netbird.exe don't -// get killed. -func isOurAgentProcess(h windows.Handle) bool { - var size uint32 = windows.MAX_PATH - buf := make([]uint16, size) - if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil { - return false - } - target := strings.ToLower(windows.UTF16ToString(buf[:size])) - selfExe, err := os.Executable() - if err != nil { - return false - } - return target == strings.ToLower(selfExe) -} - -// tcpListenerPID returns the PID of the process listening on 127.0.0.1:port, -// or 0 if none. Uses GetExtendedTcpTable with TCP_TABLE_OWNER_PID_LISTENER. -func tcpListenerPID(port uint16) uint32 { - const tcpTableOwnerPidListener = 3 - const afInet = 2 - - // MIB_TCPROW_OWNER_PID layout: state(4) + localAddr(4) + localPort(4) + - // remoteAddr(4) + remotePort(4) + owningPid(4) = 24 bytes. - const rowSize = 24 - - var size uint32 - _, _, _ = procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0, afInet, tcpTableOwnerPidListener, 0) - if size == 0 { - return 0 - } - buf := make([]byte, size) - r, _, _ := procGetExtendedTcpTable.Call( - uintptr(unsafe.Pointer(&buf[0])), - uintptr(unsafe.Pointer(&size)), - 0, afInet, tcpTableOwnerPidListener, 0, - ) - if r != 0 { - return 0 - } - count := binary.LittleEndian.Uint32(buf[:4]) - for i := uint32(0); i < count; i++ { - off := 4 + int(i)*rowSize - if off+rowSize > len(buf) { - break - } - // localPort is stored big-endian in the high 16 bits of a 32-bit field. - localPort := uint16(buf[off+8])<<8 | uint16(buf[off+9]) - if localPort != port { - continue - } - localAddr := binary.LittleEndian.Uint32(buf[off+4 : off+8]) - // 0x0100007f == 127.0.0.1 in network byte order on little-endian. - // We accept 0.0.0.0 too in case the orphan bound to all interfaces. - if localAddr != 0x0100007f && localAddr != 0 { - continue - } - return binary.LittleEndian.Uint32(buf[off+20 : off+24]) - } - return 0 -} - // wtsSessionHasUser returns true if the session has a non-empty user name, // i.e. someone is logged in (vs. the login/Welcome screen). The console // session at the lock screen has WTSUserName == "". @@ -322,7 +230,7 @@ func injectEnvVar(envBlock uintptr, key, value string) []uint16 { return newBlock } -func spawnAgentInSession(sessionID uint32, port uint16, authToken string, jobHandle windows.Handle) (windows.Handle, error) { +func spawnAgentInSession(sessionID uint32, socketPath, authToken string, jobHandle windows.Handle) (windows.Handle, error) { token, err := getSystemTokenForSession(sessionID) if err != nil { return 0, fmt.Errorf("get SYSTEM token for session %d: %w", sessionID, err) @@ -352,7 +260,7 @@ func spawnAgentInSession(sessionID uint32, port uint16, authToken string, jobHan return 0, fmt.Errorf("get executable path: %w", err) } - cmdLine := fmt.Sprintf(`"%s" %s --port %d`, exePath, vncAgentSubcommand, port) + cmdLine := fmt.Sprintf(`"%s" %s --socket %q`, exePath, vncAgentSubcommand, socketPath) cmdLineW, err := windows.UTF16PtrFromString(cmdLine) if err != nil { return 0, fmt.Errorf("UTF16 cmdline: %w", err) @@ -425,15 +333,16 @@ func spawnAgentInSession(sessionID uint32, port uint16, authToken string, jobHan // Relog agent output in the service with a [vnc-agent] prefix. go relogAgentOutput(stderrRead) - log.Infof("spawned agent PID=%d in session %d on port %d", pi.ProcessId, sessionID, port) + log.Infof("spawned agent PID=%d in session %d on %s", pi.ProcessId, sessionID, socketPath) return pi.Process, nil } // sessionManager monitors the active console session and ensures a VNC agent // process is running in it. When the session changes (e.g., user switch, RDP -// connect/disconnect), it kills the old agent and spawns a new one. +// connect/disconnect), it kills the old agent and spawns a new one. Each +// spawn picks a per-session Unix-socket path the agent binds and the +// daemon dials over local IPC. type sessionManager struct { - port uint16 mu sync.Mutex agentProc windows.Handle everSpawned bool @@ -442,16 +351,22 @@ type sessionManager struct { nextSpawnAt time.Time sessionID uint32 authToken string + socketPath string done chan struct{} // jobHandle owns the agent processes via a Windows Job Object with // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the service exits or crashes, // the OS closes the handle and terminates every assigned agent: no - // orphaned listeners holding the agent port across restarts. + // orphaned agent processes holding a socket across restarts. jobHandle windows.Handle } -func newSessionManager(port uint16) *sessionManager { - m := &sessionManager{port: port, sessionID: ^uint32(0), done: make(chan struct{})} +// agentSocketPathFmt parameterizes the per-session agent socket path by +// the Windows session id. C:\Windows\Temp is writable to both the daemon +// (SYSTEM) and the spawned agent (SYSTEM token impersonating the session). +const agentSocketPathFmt = `C:\Windows\Temp\netbird-vnc-%d.sock` + +func newSessionManager() *sessionManager { + m := &sessionManager{sessionID: ^uint32(0), done: make(chan struct{})} if h, err := createKillOnCloseJob(); err != nil { log.Warnf("create job object for vnc-agent (orphan agents possible after crash): %v", err) } else { @@ -508,13 +423,22 @@ func createKillOnCloseJob() (windows.Handle, error) { return job, nil } -// AuthToken returns the current agent authentication token. -func (m *sessionManager) AuthToken() string { +// Resolve returns the current agent socket path and token. When no +// agent is spawned yet (initial boot, between session switches, or +// permanently disabled when SE_TCB_NAME is missing) it surfaces a +// distinct error so the daemon can reject the connection with a +// meaningful message instead of timing out the proxy dial. +func (m *sessionManager) Resolve(_ context.Context) (string, string, error) { m.mu.Lock() defer m.mu.Unlock() - return m.authToken + if m.socketPath == "" { + return "", "", errAgentNotReady + } + return m.socketPath, m.authToken, nil } +var errAgentNotReady = errors.New("VNC agent not running yet") + // Stop signals the session manager to exit its polling loop and closes the // Job Object handle, which Windows uses as the trigger to terminate every // agent process this manager spawned. @@ -623,8 +547,10 @@ func (m *sessionManager) maybeSpawnAgent(sid uint32) bool { // kill an unknown listener; if a kill+respawn races on port // release, the spawn-failure backoff handles it without forcing // a synchronous wait or duplicate kill. - if !m.everSpawned { - reapOrphanOnPort(m.port) + socketPath := fmt.Sprintf(agentSocketPathFmt, sid) + // Covers a previous-run crash that escaped Job Object kill-on-close. + if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { + log.Debugf("clear stale agent socket %s: %v", socketPath, err) } token, err := generateAuthToken() if err != nil { @@ -632,9 +558,11 @@ func (m *sessionManager) maybeSpawnAgent(sid uint32) bool { return true } m.authToken = token - h, err := spawnAgentInSession(sid, m.port, m.authToken, m.jobHandle) + m.socketPath = socketPath + h, err := spawnAgentInSession(sid, socketPath, m.authToken, m.jobHandle) if err != nil { m.authToken = "" + m.socketPath = "" if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) { // SE_TCB_NAME (token-impersonation across sessions) is only // granted to SYSTEM. Without it spawnAgent will fail every 2 diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 87f31f853a6..eb384dcf2af 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -215,6 +215,11 @@ type Server struct { // during each VNC session and on session close. The engine wires // this to its metrics framework. sessionRecorder func(SessionTick) + + // preListener, when non-nil, replaces the TCP listener Start would + // open; addr/network args to Start are ignored. Used by the agent's + // Unix-socket path. + preListener net.Listener } // connAuthInfo captures the Noise_IK-verified identity bound to a live @@ -254,11 +259,9 @@ type virtualSessionManager interface { StopAll() } -// Config bundles the values the VNC server needs at construction time. -// Fields are read once by New; mutating them afterwards has no effect. -// Optional fields are nil/zero when unused. The hex-encoded AgentTokenHex -// is decoded internally and an invalid value is logged and treated as -// empty, matching the legacy SetAgentToken behavior. +// Config bundles the values the VNC server needs at construction time; +// fields are read once by New. AgentTokenHex is decoded internally; an +// invalid value is logged and treated as empty. type Config struct { Capturer ScreenCapturer Injector InputInjector @@ -268,6 +271,10 @@ type Config struct { DisableAuth bool AgentTokenHex string NetstackNet *netstack.Net + // Listener, when set, is used instead of Start opening a TCP listener; + // addr/network args to Start are then ignored. The agent uses this to + // listen on a Unix socket. + Listener net.Listener } // New creates a VNC server from the provided Config. IdentityKey is the @@ -282,6 +289,7 @@ func New(cfg Config) *Server { sessionRecorder: cfg.SessionRecorder, disableAuth: cfg.DisableAuth, netstackNet: cfg.NetstackNet, + preListener: cfg.Listener, authorizer: sshauth.NewAuthorizer(), log: log.WithField("component", "vnc-server"), sessions: make(map[uint64]ActiveSessionInfo), @@ -446,6 +454,8 @@ func (s *Server) UpdateVNCAuth(config *sshauth.Config) { // Start begins listening for VNC connections on the given address. // network is the NetBird overlay prefix used to validate connection sources. +// When Config.Listener was supplied, addr and network are ignored and the +// pre-built listener is used (the per-session agent path). func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error { s.mu.Lock() defer s.mu.Unlock() @@ -454,34 +464,37 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.P return fmt.Errorf("server already running") } - if !network.IsValid() { - return fmt.Errorf("invalid overlay network prefix") - } - s.ctx, s.cancel = context.WithCancel(ctx) s.vmgr = s.platformSessionManager() - s.localAddr = addr.Addr() - s.network = network - var listener net.Listener var listenDesc string - if s.netstackNet != nil { - ln, err := s.netstackNet.ListenTCPAddrPort(addr) - if err != nil { - return fmt.Errorf("listen on netstack %s: %w", addr, err) + switch { + case s.preListener != nil: + s.listener = s.preListener + listenDesc = s.preListener.Addr().String() + default: + if !network.IsValid() { + return fmt.Errorf("invalid overlay network prefix") } - listener = ln - listenDesc = fmt.Sprintf("netstack %s", addr) - } else { - tcpAddr := net.TCPAddrFromAddrPort(addr) - ln, err := net.ListenTCP("tcp", tcpAddr) - if err != nil { - return fmt.Errorf("listen on %s: %w", addr, err) + s.localAddr = addr.Addr() + s.network = network + if s.netstackNet != nil { + ln, err := s.netstackNet.ListenTCPAddrPort(addr) + if err != nil { + return fmt.Errorf("listen on netstack %s: %w", addr, err) + } + s.listener = ln + listenDesc = fmt.Sprintf("netstack %s", addr) + } else { + tcpAddr := net.TCPAddrFromAddrPort(addr) + ln, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", addr, err) + } + s.listener = ln + listenDesc = addr.String() } - listener = ln - listenDesc = addr.String() } - s.listener = listener if s.serviceMode { s.platformInit() @@ -616,10 +629,11 @@ func (s *Server) validateCapturer(capturer ScreenCapturer) error { // and from the local WireGuard IP (prevents local privilege escalation). // Matches the SSH server's connectionValidator logic. func (s *Server) isAllowedSource(addr net.Addr) bool { + // Unix-socket remotes (the agent path) are local IPC, gated by the + // token, not by overlay membership. tcpAddr, ok := addr.(*net.TCPAddr) if !ok { - s.log.Warnf("connection rejected: non-TCP address %s", addr) - return false + return true } remoteIP, ok := netip.AddrFromSlice(tcpAddr.IP) @@ -651,29 +665,34 @@ func (s *Server) isAllowedSource(addr net.Addr) bool { } func (s *Server) handleConnection(conn net.Conn) { + start := time.Now() connLog := s.log.WithField("remote", conn.RemoteAddr().String()) if !s.isAllowedSource(conn.RemoteAddr()) { - conn.Close() + connLog.Info("VNC connection rejected: source not allowed") + _ = conn.Close() return } if !s.verifyAgentToken(conn, connLog) { + connLog.Info("VNC connection rejected: agent token check failed") return } header, err := s.readConnectionHeader(conn) if err != nil { - connLog.Warnf("read connection header: %v", err) - conn.Close() + connLog.Infof("VNC connection rejected: header read failed: %v", err) + _ = conn.Close() return } connLog, sessionUserID, ok := s.authorizeSession(conn, header, connLog) if !ok { + connLog.Info("VNC connection rejected: auth failed") return } s.registerConnAuth(conn, header) capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog) if !ok { + connLog.Warn("VNC connection rejected: capturer/injector unavailable") return } defer sessionCleanup() @@ -688,14 +707,14 @@ func (s *Server) handleConnection(conn net.Conn) { if err := s.validateCapturer(capturer); err != nil { rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("screen capturer: %v", err))) - connLog.Warnf("capturer not ready: %v", err) + connLog.Warnf("VNC connection rejected: capturer not ready: %v", err) return } w, h := capturer.Width(), capturer.Height() if w <= 0 || h <= 0 || w > maxFramebufferDim || h > maxFramebufferDim { rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("framebuffer dimensions out of range: %dx%d", w, h))) - connLog.Warnf("rejecting session: framebuffer %dx%d outside [1, %d]", w, h, maxFramebufferDim) + connLog.Warnf("VNC connection rejected: framebuffer %dx%d outside [1, %d]", w, h, maxFramebufferDim) return } @@ -709,6 +728,7 @@ func (s *Server) handleConnection(conn net.Conn) { log: connLog, } sess.serve() + connLog.Infof("VNC connection closed (%dms)", time.Since(start).Milliseconds()) } // codeMessage formats a stable reject code with a human-readable message. diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 81c0ca9916b..18b5bbb7b1f 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -3,9 +3,6 @@ package server import ( - "bytes" - "errors" - "io" "net" log "github.com/sirupsen/logrus" @@ -23,17 +20,15 @@ func (s *Server) platformSessionManager() virtualSessionManager { return nil } -// serviceAcceptLoop runs in a LaunchDaemon and proxies each VNC -// connection to a per-user agent. The agent is spawned lazily on the -// first connection (and respawned after a console-user change) via -// launchctl asuser, which is the only mechanism that lands a child -// inside the user's Aqua session, where WindowServer and TCC grants -// for screen capture work. +// serviceAcceptLoop runs as a LaunchDaemon and proxies each VNC connection +// to the per-user agent darwinAgentManager spawns via launchctl asuser +// (the only spawn mode that lands a child in the user's Aqua session with +// WindowServer + TCC access). func (s *Server) serviceAcceptLoop() { mgr := newDarwinAgentManager(s.ctx) defer mgr.stop() - log.Infof("service mode, proxying connections to per-user agent on 127.0.0.1:%d", agentPort) + log.Info("service mode, proxying connections to per-user agent over Unix socket") for { conn, err := s.listener.Accept() @@ -58,62 +53,7 @@ func (s *Server) serviceAcceptLoop() { go func(c net.Conn) { defer s.releaseConnSlot() defer s.untrackConn(c) - s.handleServiceConnectionDarwin(c, mgr) + s.handleServiceConnection(c, mgr) }(conn) } } - -func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentManager) { - connLog := s.log.WithField("remote", conn.RemoteAddr().String()) - - if !s.isAllowedSource(conn.RemoteAddr()) { - conn.Close() - return - } - - var headerBuf bytes.Buffer - tee := io.TeeReader(conn, &headerBuf) - teeConn := &darwinPrefixConn{Reader: tee, Conn: conn} - - header, err := s.readConnectionHeader(teeConn) - if err != nil { - connLog.Debugf("read connection header: %v", err) - conn.Close() - return - } - - if !s.disableAuth { - if _, err := s.authenticateSession(header); err != nil { - rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) - connLog.Warnf("auth rejected: %v", err) - return - } - } - s.registerConnAuth(conn, header) - - token, err := mgr.ensure(s.ctx) - if err != nil { - code := RejectCodeCapturerError - if errors.Is(err, errNoConsoleUser) { - code = RejectCodeNoConsoleUser - } - rejectConnection(conn, codeMessage(code, err.Error())) - connLog.Warnf("spawn per-user agent: %v", err) - return - } - - replayConn := &darwinPrefixConn{ - Reader: io.MultiReader(&headerBuf, conn), - Conn: conn, - } - proxyToAgent(s.ctx, replayConn, agentPort, token) -} - -// darwinPrefixConn replays the already-consumed connection-header bytes -// in front of the proxy stream, mirroring the Windows prefixConn shape. -type darwinPrefixConn struct { - io.Reader - net.Conn -} - -func (p *darwinPrefixConn) Read(b []byte) (int, error) { return p.Reader.Read(b) } diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index 96a60175b6c..0a44de3e40e 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -148,11 +148,13 @@ func TestIsAllowedSource(t *testing.T) { want bool }{ { - name: "non-tcp address rejected", + // Unix-domain remotes (per-session agent path) are local IPC, + // gated by the token, not by overlay membership. + name: "non-tcp address allowed", localAddr: netip.MustParseAddr("10.99.99.1"), network: netip.MustParsePrefix("10.99.0.0/16"), - remote: &net.UDPAddr{IP: net.ParseIP("10.99.99.2"), Port: 1234}, - want: false, + remote: &net.UnixAddr{Name: "/tmp/foo.sock", Net: "unix"}, + want: true, }, { name: "own IP rejected", diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 46e1181e7e9..a8c58bd7f86 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -3,10 +3,8 @@ package server import ( - "bytes" "context" "fmt" - "io" "net" "unsafe" @@ -238,10 +236,10 @@ func (s *Server) platformInit() { // Noise_IK handshake before proxying to the user-session agent. func (s *Server) serviceAcceptLoop() { - sm := newSessionManager(agentPort) + sm := newSessionManager() go sm.run() - log.Infof("service mode, proxying connections to agent on 127.0.0.1:%d", agentPort) + log.Info("service mode, proxying connections to agent over Unix socket") for { conn, err := s.listener.Accept() @@ -272,51 +270,3 @@ func (s *Server) serviceAcceptLoop() { } } -// handleServiceConnection runs the connection-header handshake (including -// Noise_IK), then proxies the connection (with header bytes replayed) to -// the agent listening on loopback. -func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) { - connLog := s.log.WithField("remote", conn.RemoteAddr().String()) - - if !s.isAllowedSource(conn.RemoteAddr()) { - conn.Close() - return - } - - var headerBuf bytes.Buffer - tee := io.TeeReader(conn, &headerBuf) - teeConn := &prefixConn{Reader: tee, Conn: conn} - - header, err := s.readConnectionHeader(teeConn) - if err != nil { - connLog.Debugf("read connection header: %v", err) - conn.Close() - return - } - - if !s.disableAuth { - if _, err := s.authenticateSession(header); err != nil { - rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) - connLog.Warnf("auth rejected: %v", err) - return - } - } - s.registerConnAuth(conn, header) - - // Replay buffered header bytes + remaining stream to the agent. - replayConn := &prefixConn{ - Reader: io.MultiReader(&headerBuf, conn), - Conn: conn, - } - proxyToAgent(s.ctx, replayConn, agentPort, sm.AuthToken()) -} - -// prefixConn wraps a net.Conn, overriding Read to use a different reader. -type prefixConn struct { - io.Reader - net.Conn -} - -func (p *prefixConn) Read(b []byte) (int, error) { - return p.Reader.Read(b) -} From 8e72967bbe7ac09a449deab233a67e3084a63f86 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 23 May 2026 17:22:49 +0200 Subject: [PATCH 077/151] Add per-connection user-approval prompts for VNC --- client/cmd/up.go | 9 + client/cmd/vnc_flags.go | 11 +- client/internal/approval/broker.go | 188 +++++ client/internal/approval/broker_test.go | 434 ++++++++++++ client/internal/connect.go | 1 + client/internal/debug/debug.go | 3 + client/internal/engine.go | 21 +- client/internal/engine_ssh.go | 2 +- client/internal/engine_vnc.go | 62 +- client/internal/peer/status.go | 9 + client/internal/profilemanager/config.go | 14 + client/proto/daemon.pb.go | 297 ++++++-- client/proto/daemon.proto | 30 + client/proto/daemon_grpc.pb.go | 50 ++ client/server/server.go | 23 + client/server/setconfig_test.go | 6 + client/ssh/proxy/proxy_test.go | 2 +- client/ssh/server/jwt_test.go | 2 +- client/ssh/server/server.go | 2 +- client/ui/approval.go | 192 +++++ client/ui/client_ui.go | 77 +- client/ui/event/event.go | 2 +- client/vnc/server/agent_ipc.go | 29 +- client/vnc/server/noise_auth_test.go | 2 +- client/vnc/server/server.go | 165 ++++- client/vnc/server/server_test.go | 181 +++++ client/vnc/server/session.go | 50 +- client/wasm/internal/vnc/proxy.go | 20 +- .../internals/shared/grpc/conversion.go | 7 +- .../http/handlers/peers/peers_handler.go | 29 + management/server/types/account.go | 2 +- .../server/types/networkmap_components.go | 2 +- .../server/types/policy_authorized_users.go | 10 +- management/server/types/policyrule.go | 11 +- shared/management/proto/management.pb.go | 661 +++++++++--------- shared/management/proto/management.proto | 7 + .../ssh/auth => shared/sessionauth}/auth.go | 42 +- .../auth => shared/sessionauth}/auth_test.go | 2 +- 38 files changed, 2174 insertions(+), 483 deletions(-) create mode 100644 client/internal/approval/broker.go create mode 100644 client/internal/approval/broker_test.go create mode 100644 client/ui/approval.go rename {client/ssh/auth => shared/sessionauth}/auth.go (85%) rename {client/ssh/auth => shared/sessionauth}/auth_test.go (99%) diff --git a/client/cmd/up.go b/client/cmd/up.go index 167b418fbe5..8ffc7c2f761 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -364,6 +364,9 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro if cmd.Flag(serverVNCAllowedFlag).Changed { req.ServerVNCAllowed = &serverVNCAllowed } + if cmd.Flag(disableVNCApprovalFlag).Changed { + req.DisableVNCApproval = &disableVNCApproval + } if cmd.Flag(enableSSHRootFlag).Changed { req.EnableSSHRoot = &enableSSHRoot } @@ -473,6 +476,9 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil if cmd.Flag(serverVNCAllowedFlag).Changed { ic.ServerVNCAllowed = &serverVNCAllowed } + if cmd.Flag(disableVNCApprovalFlag).Changed { + ic.DisableVNCApproval = &disableVNCApproval + } if cmd.Flag(enableSSHRootFlag).Changed { ic.EnableSSHRoot = &enableSSHRoot @@ -604,6 +610,9 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte if cmd.Flag(serverVNCAllowedFlag).Changed { loginRequest.ServerVNCAllowed = &serverVNCAllowed } + if cmd.Flag(disableVNCApprovalFlag).Changed { + loginRequest.DisableVNCApproval = &disableVNCApproval + } if cmd.Flag(enableSSHRootFlag).Changed { loginRequest.EnableSSHRoot = &enableSSHRoot diff --git a/client/cmd/vnc_flags.go b/client/cmd/vnc_flags.go index cfcbaeab1f5..b08a4fe2b84 100644 --- a/client/cmd/vnc_flags.go +++ b/client/cmd/vnc_flags.go @@ -1,9 +1,16 @@ package cmd -const serverVNCAllowedFlag = "allow-server-vnc" +const ( + serverVNCAllowedFlag = "allow-server-vnc" + disableVNCApprovalFlag = "disable-vnc-approval" +) -var serverVNCAllowed bool +var ( + serverVNCAllowed bool + disableVNCApproval bool +) func init() { upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer") + upCmd.PersistentFlags().BoolVar(&disableVNCApproval, disableVNCApprovalFlag, false, "Disable per-connection user approval prompts for the embedded VNC server") } diff --git a/client/internal/approval/broker.go b/client/internal/approval/broker.go new file mode 100644 index 00000000000..0cc0d95148d --- /dev/null +++ b/client/internal/approval/broker.go @@ -0,0 +1,188 @@ +// Package approval brokers per-attempt user-accept prompts for inbound +// remote access (VNC today, SSH and others in the future). A caller pushes +// a Prompt; the broker emits a SystemEvent on the daemon→UI stream and +// blocks until the UI calls the daemon's RespondApproval RPC, the per- +// request timeout fires, or no subscriber is connected. The latter case +// fails closed so a backgrounded UI cannot silently bypass the gate. +package approval + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// Metadata keys the broker reserves on the emitted SystemEvent. Callers +// should not set these themselves; values in Prompt.Metadata that collide +// are overwritten by the broker. +const ( + MetaRequestID = "request_id" + MetaKind = "kind" + MetaExpiresAt = "expires_at" +) + +// Kind values for the well-known prompt subjects. New subsystems should +// add a constant here so the UI can dispatch on a known string. +const ( + KindVNC = "vnc" + KindSSH = "ssh" +) + +// DefaultTimeout is the wall-clock window the user has to accept or deny a +// pending approval before the broker fails closed and returns ErrTimeout. +// Kept well under typical VNC client and dashboard connection timeouts so +// the RFB rejection actually reaches the browser instead of racing the +// browser's own "connection timed out" message. +const DefaultTimeout = 15 * time.Second + +// timeoutValue returns the active timeout. It's a var so tests in this +// package can shorten the wait without exposing a setter on the public +// API. Production code always sees DefaultTimeout. +var timeoutValue = func() time.Duration { return DefaultTimeout } + +// ErrNoSubscriber indicates no UI is connected to consume the prompt. +// The caller must reject the underlying connection (fail-closed). +var ErrNoSubscriber = errors.New("no UI subscriber connected for approval") + +// ErrTimeout indicates the user did not respond within DefaultTimeout. +var ErrTimeout = errors.New("approval timed out") + +// ErrDenied indicates the user explicitly denied the connection. +var ErrDenied = errors.New("approval denied") + +// EventPublisher is the subset of peer.Status used to emit prompts. +type EventPublisher interface { + PublishEvent( + severity proto.SystemEvent_Severity, + category proto.SystemEvent_Category, + msg string, + userMsg string, + metadata map[string]string, + ) + HasEventSubscribers() bool +} + +// Prompt describes the pending request shown to the user. Kind selects +// the UI dispatch path (e.g. "vnc", "ssh"). Subject is the human-readable +// one-liner the UI may show as a title or notification body. Metadata is +// passed through verbatim and is the subsystem-specific payload (peer +// name, source IP, mode, etc.). +type Prompt struct { + Kind string + Subject string + Metadata map[string]string +} + +// Decision carries the user's response to an approval prompt. ViewOnly is +// only meaningful when Accept is true; it lets the host grant the +// connection but signal the requester that input control is withheld. +type Decision struct { + Accept bool + ViewOnly bool +} + +// Broker holds in-flight approval requests keyed by request ID. +type Broker struct { + pub EventPublisher + + mu sync.Mutex + pending map[string]chan Decision +} + +// New returns a broker that publishes prompts via pub. +func New(pub EventPublisher) *Broker { + return &Broker{ + pub: pub, + pending: make(map[string]chan Decision), + } +} + +// Request emits a SystemEvent for p and blocks until the UI calls Respond, +// ctx is cancelled, or DefaultTimeout elapses. Returns a Decision when +// the user replied; ErrDenied / ErrTimeout / ErrNoSubscriber / ctx.Err +// otherwise. Callers must treat any non-nil error as a deny. +func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) { + var zero Decision + if b == nil || b.pub == nil { + return zero, fmt.Errorf("approval broker not configured") + } + if !b.pub.HasEventSubscribers() { + return zero, ErrNoSubscriber + } + + id := uuid.NewString() + resp := make(chan Decision, 1) + + b.mu.Lock() + b.pending[id] = resp + b.mu.Unlock() + + defer b.dropPending(id) + + timeout := timeoutValue() + expiresAt := time.Now().Add(timeout) + meta := make(map[string]string, len(p.Metadata)+3) + for k, v := range p.Metadata { + meta[k] = v + } + meta[MetaRequestID] = id + meta[MetaKind] = p.Kind + meta[MetaExpiresAt] = expiresAt.UTC().Format(time.RFC3339) + + subject := p.Subject + if subject == "" { + subject = fmt.Sprintf("%s connection requires approval", p.Kind) + } + b.pub.PublishEvent(proto.SystemEvent_INFO, proto.SystemEvent_APPROVAL, subject, subject, meta) + log.Debugf("approval request %s (%s) emitted: %s", id, p.Kind, subject) + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case d := <-resp: + if !d.Accept { + return zero, ErrDenied + } + return d, nil + case <-timer.C: + return zero, ErrTimeout + case <-ctx.Done(): + return zero, ctx.Err() + } +} + +// Respond delivers the user's decision for id. Returns true when a pending +// request matched and was woken, false when id was unknown or already done. +func (b *Broker) Respond(id string, d Decision) bool { + if b == nil { + return false + } + b.mu.Lock() + ch, ok := b.pending[id] + if ok { + delete(b.pending, id) + } + b.mu.Unlock() + if !ok { + return false + } + select { + case ch <- d: + default: + } + return true +} + +func (b *Broker) dropPending(id string) { + b.mu.Lock() + delete(b.pending, id) + b.mu.Unlock() +} diff --git a/client/internal/approval/broker_test.go b/client/internal/approval/broker_test.go new file mode 100644 index 00000000000..a8e748468d8 --- /dev/null +++ b/client/internal/approval/broker_test.go @@ -0,0 +1,434 @@ +package approval + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +// fakePublisher records published events and reports whether subscribers +// are connected. The subscribers flag is the security-critical signal: +// when false the broker must refuse to emit and the gate must fail closed. +type fakePublisher struct { + mu sync.Mutex + subscribers bool + events []*proto.SystemEvent +} + +func (p *fakePublisher) PublishEvent( + severity proto.SystemEvent_Severity, + category proto.SystemEvent_Category, + msg string, + userMsg string, + metadata map[string]string, +) { + p.mu.Lock() + p.events = append(p.events, &proto.SystemEvent{ + Severity: severity, + Category: category, + Message: msg, + UserMessage: userMsg, + Metadata: metadata, + }) + p.mu.Unlock() +} + +func (p *fakePublisher) HasEventSubscribers() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.subscribers +} + +func (p *fakePublisher) lastEvent(t *testing.T) *proto.SystemEvent { + t.Helper() + p.mu.Lock() + defer p.mu.Unlock() + require.NotEmpty(t, p.events, "publisher saw no events") + return p.events[len(p.events)-1] +} + +func (p *fakePublisher) eventCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.events) +} + +// TestRequestNoSubscriberFailsClosed is the core fail-closed invariant: +// when the UI is not subscribed, the broker must refuse without emitting +// an event or arming a waiter. A regression here is a silent bypass. +func TestRequestNoSubscriberFailsClosed(t *testing.T) { + pub := &fakePublisher{subscribers: false} + b := New(pub) + + _, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"}) + assert.ErrorIs(t, err, ErrNoSubscriber) + assert.Equal(t, 0, pub.eventCount(), "no event must be emitted when fail-closed") + + b.mu.Lock() + pending := len(b.pending) + b.mu.Unlock() + assert.Equal(t, 0, pending, "no waiter must be registered on fail-closed") +} + +// TestRequestTimeoutDenies verifies that a request without a UI response +// returns ErrTimeout (deny) rather than nil (silent accept). Uses a short +// per-test broker timeout via Respond after the fact to keep the test fast. +func TestRequestTimeoutDenies(t *testing.T) { + // Replace DefaultTimeout for the lifetime of this test. + orig := DefaultTimeout + defaultTimeout(t, 60*time.Millisecond) + defer defaultTimeout(t, orig) + + pub := &fakePublisher{subscribers: true} + b := New(pub) + + start := time.Now() + _, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"}) + assert.ErrorIs(t, err, ErrTimeout, "missing user response must yield ErrTimeout, not nil") + assert.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond, "timeout fired prematurely") +} + +// TestRequestDenied returns ErrDenied when the UI responds with false. +func TestRequestDenied(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + var requestID string + done := make(chan error, 1) + go func() { + done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"}) + }() + + requestID = waitForRequestID(t, pub) + require.True(t, b.Respond(requestID, Decision{Accept: false})) + + select { + case err := <-done: + assert.ErrorIs(t, err, ErrDenied) + case <-time.After(time.Second): + t.Fatal("Request did not return after Respond(false)") + } +} + +// TestRequestAccepted is the happy path. Failure here doesn't bypass the +// gate but breaks the feature. +func TestRequestAccepted(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + done := make(chan error, 1) + go func() { + done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"}) + }() + + id := waitForRequestID(t, pub) + require.True(t, b.Respond(id, Decision{Accept: true})) + + select { + case err := <-done: + assert.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("Request did not return after Respond(true)") + } +} + +// TestRequestCtxCancelDenies verifies that an upstream cancel (e.g. the +// engine shutting down mid-prompt) returns the cancel error rather than +// nil. A nil here would be a silent bypass on shutdown races. +func TestRequestCtxCancelDenies(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- requestErr(b, ctx, Prompt{Kind: KindVNC, Subject: "test"}) + }() + + // Wait until the prompt is in flight so cancel races a live waiter. + _ = waitForRequestID(t, pub) + cancel() + + select { + case err := <-done: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("Request did not return after ctx cancel") + } +} + +// TestRespondUnknownIsNoop ensures a stray RespondApproval RPC cannot +// affect or accidentally accept any in-flight request whose id it doesn't +// match. Also confirms it doesn't panic. +func TestRespondUnknownIsNoop(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + // No in-flight prompts: Respond returns false. + assert.False(t, b.Respond("does-not-exist", Decision{Accept: true})) + + // With an in-flight prompt, a wrong id still returns false and the + // prompt remains armed (eventually timing out as a deny). + defaultTimeout(t, 60*time.Millisecond) + defer defaultTimeout(t, DefaultTimeout) + + done := make(chan error, 1) + go func() { + done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC}) + }() + realID := waitForRequestID(t, pub) + assert.False(t, b.Respond("totally-bogus", Decision{Accept: true}), "unknown id must not match") + assert.NotEqual(t, "totally-bogus", realID) + + select { + case err := <-done: + assert.ErrorIs(t, err, ErrTimeout, "armed prompt must still time out, not accept") + case <-time.After(time.Second): + t.Fatal("prompt did not resolve") + } +} + +// TestRespondAfterTimeoutNoop confirms a late accept response can't +// retroactively flip a denied (timed-out) request. The dropPending defer +// in Request must have removed the entry by the time Respond races in. +func TestRespondAfterTimeoutNoop(t *testing.T) { + defaultTimeout(t, 30*time.Millisecond) + defer defaultTimeout(t, DefaultTimeout) + + pub := &fakePublisher{subscribers: true} + b := New(pub) + + done := make(chan error, 1) + go func() { + done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC}) + }() + id := waitForRequestID(t, pub) + + select { + case err := <-done: + require.ErrorIs(t, err, ErrTimeout) + case <-time.After(time.Second): + t.Fatal("prompt did not time out") + } + + assert.False(t, b.Respond(id, Decision{Accept: true}), "late respond must be no-op") +} + +// TestRespondDoubleNoop ensures a duplicate ack from the UI doesn't leak +// past the matched waiter or panic on a closed/full channel. +func TestRespondDoubleNoop(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + done := make(chan error, 1) + go func() { + done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC}) + }() + id := waitForRequestID(t, pub) + require.True(t, b.Respond(id, Decision{Accept: true})) + assert.False(t, b.Respond(id, Decision{Accept: false}), "second response must be no-op") + + select { + case err := <-done: + assert.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("prompt did not resolve") + } +} + +// TestNilBrokerRequestErrors guards the engine pre-init path where the +// broker may not yet exist (or its publisher is nil): Request must +// error, never silently accept. +func TestNilBrokerRequestErrors(t *testing.T) { + var b *Broker + _, err := b.Request(context.Background(), Prompt{Kind: KindVNC}) + assert.Error(t, err, "nil broker must error, never silently accept") + + b2 := New(nil) + _, err = b2.Request(context.Background(), Prompt{Kind: KindVNC}) + assert.Error(t, err, "broker with nil publisher must error, never silently accept") +} + +// TestPromptMetadataInjected confirms the broker stamps request_id, kind, +// and expires_at on the emitted event. The UI relies on these keys; if +// they are dropped, the user cannot route the prompt and the response +// path breaks (which fails closed via timeout). +func TestPromptMetadataInjected(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + done := make(chan error, 1) + go func() { + done <- requestErr(b, context.Background(), Prompt{ + Kind: KindVNC, + Subject: "VNC connection from peerA", + Metadata: map[string]string{"peer_name": "peerA"}, + }) + }() + + id := waitForRequestID(t, pub) + ev := pub.lastEvent(t) + + assert.Equal(t, proto.SystemEvent_APPROVAL, ev.Category) + assert.Equal(t, KindVNC, ev.Metadata[MetaKind]) + assert.Equal(t, id, ev.Metadata[MetaRequestID]) + assert.NotEmpty(t, ev.Metadata[MetaExpiresAt]) + assert.Equal(t, "peerA", ev.Metadata["peer_name"], "caller metadata must pass through") + + require.True(t, b.Respond(id, Decision{Accept: true})) + <-done +} + +// TestConcurrentRequests verifies that two concurrent prompts are tracked +// independently. A bug that aliases ids would let one Respond unblock +// the wrong waiter (a silent accept across prompts). +func TestConcurrentRequests(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + const n = 20 + results := make(chan error, n) + for i := 0; i < n; i++ { + go func() { + results <- requestErr(b, context.Background(), Prompt{Kind: KindVNC}) + }() + } + + ids := waitForNRequestIDs(t, pub, n) + require.Len(t, ids, n) + + // Deny exactly half, accept the rest. Track outcome per id so we can + // match each Request's return value against the response we sent. + denySet := make(map[string]bool, n) + for i, id := range ids { + deny := i%2 == 0 + denySet[id] = deny + require.True(t, b.Respond(id, Decision{Accept: !deny})) + } + + // Collect all returns and check no nil errors slipped past a deny. + var accepted, denied atomic.Int32 + for i := 0; i < n; i++ { + select { + case err := <-results: + if err == nil { + accepted.Add(1) + } else { + assert.ErrorIs(t, err, ErrDenied) + denied.Add(1) + } + case <-time.After(2 * time.Second): + t.Fatalf("only got %d/%d responses", i, n) + } + } + assert.Equal(t, int32(n/2), denied.Load()) + assert.Equal(t, int32(n/2), accepted.Load()) +} + +// waitForRequestID blocks until the publisher sees its next event and +// returns the request_id stamped on it. +func waitForRequestID(t *testing.T, pub *fakePublisher) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + pub.mu.Lock() + count := len(pub.events) + var id string + if count > 0 { + id = pub.events[count-1].Metadata[MetaRequestID] + } + pub.mu.Unlock() + if id != "" { + return id + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("timeout waiting for emitted event") + return "" +} + +func waitForNRequestIDs(t *testing.T, pub *fakePublisher, n int) []string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + pub.mu.Lock() + count := len(pub.events) + pub.mu.Unlock() + if count >= n { + break + } + time.Sleep(2 * time.Millisecond) + } + pub.mu.Lock() + defer pub.mu.Unlock() + out := make([]string, 0, len(pub.events)) + seen := make(map[string]struct{}, len(pub.events)) + for _, ev := range pub.events { + id := ev.Metadata[MetaRequestID] + if id == "" { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + if len(out) < n { + t.Fatalf("only got %d/%d request ids", len(out), n) + } + return out +} + +// defaultTimeout swaps the broker's per-request wall-clock window so the +// timeout tests run quickly. Restores the prior value on the next call. +func defaultTimeout(t *testing.T, d time.Duration) { + t.Helper() + if d <= 0 { + t.Fatal("defaultTimeout must be > 0") + } + timeoutValue = func() time.Duration { return d } +} + +// requestErr wraps Broker.Request to drop the Decision when tests only +// care about the error path. Keeps the goroutine bodies tight. +func requestErr(b *Broker, ctx context.Context, p Prompt) error { + _, err := b.Request(ctx, p) + return err +} + +// TestRequestViewOnly checks the view-only outcome flows through Request's +// Decision return without being silently swallowed. +func TestRequestViewOnly(t *testing.T) { + pub := &fakePublisher{subscribers: true} + b := New(pub) + + type result struct { + d Decision + err error + } + done := make(chan result, 1) + go func() { + d, err := b.Request(context.Background(), Prompt{Kind: KindVNC}) + done <- result{d, err} + }() + + id := waitForRequestID(t, pub) + require.True(t, b.Respond(id, Decision{Accept: true, ViewOnly: true})) + + select { + case r := <-done: + assert.NoError(t, r.err) + assert.True(t, r.d.Accept) + assert.True(t, r.d.ViewOnly, "ViewOnly must survive the round-trip") + case <-time.After(time.Second): + t.Fatal("view-only request did not resolve") + } +} diff --git a/client/internal/connect.go b/client/internal/connect.go index af1b6a9cd7f..ade43e9f480 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -563,6 +563,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf RosenpassPermissive: config.RosenpassPermissive, ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed), ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed, + DisableVNCApproval: config.DisableVNCApproval, EnableSSHRoot: config.EnableSSHRoot, EnableSSHSFTP: config.EnableSSHSFTP, EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding, diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 09ac2c2cf90..f259d4e1d5b 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -639,6 +639,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) if g.internalConfig.ServerVNCAllowed != nil { configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed)) } + if g.internalConfig.DisableVNCApproval != nil { + configContent.WriteString(fmt.Sprintf("DisableVNCApproval: %v\n", *g.internalConfig.DisableVNCApproval)) + } configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes)) configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes)) diff --git a/client/internal/engine.go b/client/internal/engine.go index 98d4b9fb6b3..3b575a88903 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -35,6 +35,7 @@ import ( "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/acl" + "github.com/netbirdio/netbird/client/internal/approval" "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" @@ -124,6 +125,7 @@ type EngineConfig struct { ServerSSHAllowed bool ServerVNCAllowed bool + DisableVNCApproval *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -205,8 +207,9 @@ type Engine struct { networkMonitor *networkmonitor.NetworkMonitor - sshServer sshServer - vncSrv vncServer + sshServer sshServer + vncSrv vncServer + approvalBroker *approval.Broker statusRecorder *peer.Status @@ -287,6 +290,7 @@ func NewEngine( TURNs: []*stun.URI{}, networkSerial: 0, statusRecorder: services.StatusRecorder, + approvalBroker: approval.New(services.StatusRecorder), stateManager: services.StateManager, portForwardManager: portforward.NewManager(), checks: services.Checks, @@ -2608,3 +2612,16 @@ func decodeRelayIP(b []byte) netip.Addr { } return ip.Unmap() } + +// RespondApproval relays the user's decision for a pending approval to +// the broker. viewOnly is honoured only when accept is true. Returns +// true when the request_id matched a live prompt. +func (e *Engine) RespondApproval(requestID string, accept, viewOnly bool) bool { + if e == nil || e.approvalBroker == nil { + return false + } + return e.approvalBroker.Respond(requestID, approval.Decision{ + Accept: accept, + ViewOnly: accept && viewOnly, + }) +} diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index e02bcba369b..17296d6d719 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -12,7 +12,7 @@ import ( firewallManager "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/netstack" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" sshconfig "github.com/netbirdio/netbird/client/ssh/config" sshserver "github.com/netbirdio/netbird/client/ssh/server" mgmProto "github.com/netbirdio/netbird/shared/management/proto" diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index dfb63345c0b..82b92146e71 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -11,9 +11,11 @@ import ( log "github.com/sirupsen/logrus" firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/approval" "github.com/netbirdio/netbird/client/internal/metrics" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" + "github.com/netbirdio/netbird/client/internal/peer" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" vncserver "github.com/netbirdio/netbird/client/vnc/server" mgmProto "github.com/netbirdio/netbird/shared/management/proto" sshuserhash "github.com/netbirdio/netbird/shared/sshauth" @@ -118,6 +120,7 @@ func (e *Engine) startVNCServer() error { if serviceMode { log.Info("VNC: running as system service, enabling service mode (per-session agent proxy)") } + requireApproval := e.config.DisableVNCApproval == nil || !*e.config.DisableVNCApproval srv := vncserver.New(vncserver.Config{ Capturer: capturer, Injector: injector, @@ -125,6 +128,8 @@ func (e *Engine) startVNCServer() error { ServiceMode: serviceMode, SessionRecorder: sessionRecorder, NetstackNet: e.wgInterface.GetNet(), + RequireApproval: requireApproval, + Approver: &vncApprover{broker: e.approvalBroker, statusRecorder: e.statusRecorder}, }) listenAddr := netip.AddrPortFrom(netbirdIP, vncInternalPort) @@ -152,7 +157,6 @@ func (e *Engine) startVNCServer() error { return nil } - // updateVNCServerAuth updates VNC fine-grained access control from management. func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { if vncAuth == nil || e.vncSrv == nil { @@ -192,8 +196,9 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { continue } sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{ - PubKey: pub, - UserIDHash: sshuserhash.UserIDHash(hash), + PubKey: pub, + UserIDHash: sshuserhash.UserIDHash(hash), + DisplayName: e.GetDisplayName(), }) } @@ -238,3 +243,52 @@ func (e *Engine) stopVNCServer() error { } return nil } + +// vncApprover adapts the generic approval.Broker for the VNC server. +type vncApprover struct { + broker *approval.Broker + statusRecorder *peer.Status +} + +func (a *vncApprover) Request(ctx context.Context, info vncserver.ApprovalInfo) (vncserver.ApprovalDecision, error) { + // Resolve the source overlay IP to a peer FQDN for the prompt label. + if info.PeerName == "" && info.SourceIP != "" && a.statusRecorder != nil { + if fqdn, ok := a.statusRecorder.PeerByIP(info.SourceIP); ok { + info.PeerName = fqdn + } + } + subject := fmt.Sprintf("VNC connection from %s", displayPeer(info)) + meta := map[string]string{ + "peer_name": info.PeerName, + "peer_pubkey": info.PeerPubKey, + "source_ip": info.SourceIP, + "mode": info.Mode, + "username": info.Username, + "initiator": info.Initiator, + } + d, err := a.broker.Request(ctx, approval.Prompt{ + Kind: approval.KindVNC, + Subject: subject, + Metadata: meta, + }) + if err != nil { + return vncserver.ApprovalDecision{}, err + } + return vncserver.ApprovalDecision{ViewOnly: d.ViewOnly}, nil +} + +func displayPeer(info vncserver.ApprovalInfo) string { + if info.Initiator != "" { + return info.Initiator + } + if info.PeerName != "" { + return info.PeerName + } + if info.SourceIP != "" { + return info.SourceIP + } + if info.PeerPubKey != "" { + return info.PeerPubKey + } + return "unknown peer" +} diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index df746fa138c..c74a3ed8c5a 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1191,6 +1191,15 @@ func (d *Status) SubscribeToEvents() *EventSubscription { } } +// HasEventSubscribers reports whether any client is currently subscribed +// to the daemon's SystemEvent stream. Used by the VNC approval broker to +// fail closed when no UI is connected to prompt the user. +func (d *Status) HasEventSubscribers() bool { + d.eventMux.Lock() + defer d.eventMux.Unlock() + return len(d.eventStreams) > 0 +} + // UnsubscribeFromEvents removes an event subscription func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) { if sub == nil { diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 2d98e8cf784..a255a92c3c9 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -66,6 +66,7 @@ type ConfigInput struct { PreSharedKey *string ServerSSHAllowed *bool ServerVNCAllowed *bool + DisableVNCApproval *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -118,6 +119,7 @@ type Config struct { RosenpassPermissive bool ServerSSHAllowed *bool ServerVNCAllowed *bool + DisableVNCApproval *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -435,6 +437,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.DisableVNCApproval != nil { + if config.DisableVNCApproval == nil || *input.DisableVNCApproval != *config.DisableVNCApproval { + if *input.DisableVNCApproval { + log.Infof("disabling VNC connection approval prompt") + } else { + log.Infof("enabling VNC connection approval prompt") + } + config.DisableVNCApproval = input.DisableVNCApproval + updated = true + } + } + if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index ec30f1ede4d..d5cb6927742 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -203,6 +203,7 @@ const ( SystemEvent_AUTHENTICATION SystemEvent_Category = 2 SystemEvent_CONNECTIVITY SystemEvent_Category = 3 SystemEvent_SYSTEM SystemEvent_Category = 4 + SystemEvent_APPROVAL SystemEvent_Category = 5 ) // Enum value maps for SystemEvent_Category. @@ -213,6 +214,7 @@ var ( 2: "AUTHENTICATION", 3: "CONNECTIVITY", 4: "SYSTEM", + 5: "APPROVAL", } SystemEvent_Category_value = map[string]int32{ "NETWORK": 0, @@ -220,6 +222,7 @@ var ( "AUTHENTICATION": 2, "CONNECTIVITY": 3, "SYSTEM": 4, + "APPROVAL": 5, } ) @@ -344,6 +347,7 @@ type LoginRequest struct { SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` ServerVNCAllowed *bool `protobuf:"varint,41,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval *bool `protobuf:"varint,42,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -666,6 +670,13 @@ func (x *LoginRequest) GetServerVNCAllowed() bool { return false } +func (x *LoginRequest) GetDisableVNCApproval() bool { + if x != nil && x.DisableVNCApproval != nil { + return *x.DisableVNCApproval + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -1200,6 +1211,7 @@ type GetConfigResponse struct { SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` ServerVNCAllowed bool `protobuf:"varint,28,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval bool `protobuf:"varint,29,opt,name=disableVNCApproval,proto3" json:"disableVNCApproval,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1430,6 +1442,13 @@ func (x *GetConfigResponse) GetServerVNCAllowed() bool { return false } +func (x *GetConfigResponse) GetDisableVNCApproval() bool { + if x != nil { + return x.DisableVNCApproval + } + return false +} + // PeerState contains the latest state of a peer type PeerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4191,6 +4210,7 @@ type SetConfigRequest struct { SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` ServerVNCAllowed *bool `protobuf:"varint,36,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval *bool `protobuf:"varint,37,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4477,6 +4497,13 @@ func (x *SetConfigRequest) GetServerVNCAllowed() bool { return false } +func (x *SetConfigRequest) GetDisableVNCApproval() bool { + if x != nil && x.DisableVNCApproval != nil { + return *x.DisableVNCApproval + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -6325,6 +6352,109 @@ func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{95} } +type RespondApprovalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // request_id matches the SystemEvent metadata key emitted by the daemon + // when a subsystem awaits user approval for an inbound connection. + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // accept is true if the user approved the request, false if they + // denied it. A missing or unknown request_id is treated as a no-op. + Accept bool `protobuf:"varint,2,opt,name=accept,proto3" json:"accept,omitempty"` + // view_only signals that the user granted the connection but withheld + // input control. Only meaningful when accept is true; ignored when + // accept is false. + ViewOnly bool `protobuf:"varint,3,opt,name=view_only,json=viewOnly,proto3" json:"view_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RespondApprovalRequest) Reset() { + *x = RespondApprovalRequest{} + mi := &file_daemon_proto_msgTypes[96] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RespondApprovalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RespondApprovalRequest) ProtoMessage() {} + +func (x *RespondApprovalRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[96] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RespondApprovalRequest.ProtoReflect.Descriptor instead. +func (*RespondApprovalRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{96} +} + +func (x *RespondApprovalRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *RespondApprovalRequest) GetAccept() bool { + if x != nil { + return x.Accept + } + return false +} + +func (x *RespondApprovalRequest) GetViewOnly() bool { + if x != nil { + return x.ViewOnly + } + return false +} + +type RespondApprovalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RespondApprovalResponse) Reset() { + *x = RespondApprovalResponse{} + mi := &file_daemon_proto_msgTypes[97] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RespondApprovalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RespondApprovalResponse) ProtoMessage() {} + +func (x *RespondApprovalResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[97] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RespondApprovalResponse.ProtoReflect.Descriptor instead. +func (*RespondApprovalResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{97} +} + type PortInfo_Range struct { state protoimpl.MessageState `protogen:"open.v1"` Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"` @@ -6335,7 +6465,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[97] + mi := &file_daemon_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6347,7 +6477,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[97] + mi := &file_daemon_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6382,7 +6512,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xb5\x13\n" + + "\fEmptyRequest\"\x81\x14\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -6428,7 +6558,8 @@ const file_daemon_proto_rawDesc = "" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x12/\n" + - "\x10serverVNCAllowed\x18) \x01(\bH\x1cR\x10serverVNCAllowed\x88\x01\x01B\x13\n" + + "\x10serverVNCAllowed\x18) \x01(\bH\x1cR\x10serverVNCAllowed\x88\x01\x01\x123\n" + + "\x12disableVNCApproval\x18* \x01(\bH\x1dR\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6457,7 +6588,8 @@ const file_daemon_proto_rawDesc = "" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + "\r_disable_ipv6B\x13\n" + - "\x11_serverVNCAllowed\"\xb5\x01\n" + + "\x11_serverVNCAllowedB\x15\n" + + "\x13_disableVNCApproval\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -6490,7 +6622,7 @@ const file_daemon_proto_rawDesc = "" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xda\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -6523,7 +6655,8 @@ const file_daemon_proto_rawDesc = "" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + - "\x10serverVNCAllowed\x18\x1c \x01(\bR\x10serverVNCAllowed\"\x92\x06\n" + + "\x10serverVNCAllowed\x18\x1c \x01(\bR\x10serverVNCAllowed\x12.\n" + + "\x12disableVNCApproval\x18\x1d \x01(\bR\x12disableVNCApproval\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" + @@ -6715,7 +6848,7 @@ const file_daemon_proto_rawDesc = "" + "\x13TracePacketResponse\x12*\n" + "\x06stages\x18\x01 \x03(\v2\x12.daemon.TraceStageR\x06stages\x12+\n" + "\x11final_disposition\x18\x02 \x01(\bR\x10finalDisposition\"\x12\n" + - "\x10SubscribeRequest\"\x93\x04\n" + + "\x10SubscribeRequest\"\xa1\x04\n" + "\vSystemEvent\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x128\n" + "\bseverity\x18\x02 \x01(\x0e2\x1c.daemon.SystemEvent.SeverityR\bseverity\x128\n" + @@ -6731,14 +6864,15 @@ const file_daemon_proto_rawDesc = "" + "\x04INFO\x10\x00\x12\v\n" + "\aWARNING\x10\x01\x12\t\n" + "\x05ERROR\x10\x02\x12\f\n" + - "\bCRITICAL\x10\x03\"R\n" + + "\bCRITICAL\x10\x03\"`\n" + "\bCategory\x12\v\n" + "\aNETWORK\x10\x00\x12\a\n" + "\x03DNS\x10\x01\x12\x12\n" + "\x0eAUTHENTICATION\x10\x02\x12\x10\n" + "\fCONNECTIVITY\x10\x03\x12\n" + "\n" + - "\x06SYSTEM\x10\x04\"\x12\n" + + "\x06SYSTEM\x10\x04\x12\f\n" + + "\bAPPROVAL\x10\x05\"\x12\n" + "\x10GetEventsRequest\"@\n" + "\x11GetEventsResponse\x12+\n" + "\x06events\x18\x01 \x03(\v2\x13.daemon.SystemEventR\x06events\"{\n" + @@ -6747,7 +6881,7 @@ const file_daemon_proto_rawDesc = "" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + "\t_username\"\x17\n" + - "\x15SwitchProfileResponse\"\xde\x11\n" + + "\x15SwitchProfileResponse\"\xaa\x12\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -6788,7 +6922,8 @@ const file_daemon_proto_rawDesc = "" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x12/\n" + - "\x10serverVNCAllowed\x18$ \x01(\bH\x19R\x10serverVNCAllowed\x88\x01\x01B\x13\n" + + "\x10serverVNCAllowed\x18$ \x01(\bH\x19R\x10serverVNCAllowed\x88\x01\x01\x123\n" + + "\x12disableVNCApproval\x18% \x01(\bH\x1aR\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6814,7 +6949,8 @@ const file_daemon_proto_rawDesc = "" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + "\r_disable_ipv6B\x13\n" + - "\x11_serverVNCAllowed\"\x13\n" + + "\x11_serverVNCAllowedB\x15\n" + + "\x13_disableVNCApproval\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + @@ -6925,7 +7061,13 @@ const file_daemon_proto_rawDesc = "" + "\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" + "\x1aStartBundleCaptureResponse\"\x1a\n" + "\x18StopBundleCaptureRequest\"\x1b\n" + - "\x19StopBundleCaptureResponse*b\n" + + "\x19StopBundleCaptureResponse\"l\n" + + "\x16RespondApprovalRequest\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" + + "\x06accept\x18\x02 \x01(\bR\x06accept\x12\x1b\n" + + "\tview_only\x18\x03 \x01(\bR\bviewOnly\"\x19\n" + + "\x17RespondApprovalResponse*b\n" + "\bLogLevel\x12\v\n" + "\aUNKNOWN\x10\x00\x12\t\n" + "\x05PANIC\x10\x01\x12\t\n" + @@ -6943,7 +7085,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xaf\x17\n" + + "EXPOSE_TLS\x10\x042\x85\x18\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -6986,7 +7128,8 @@ const file_daemon_proto_rawDesc = "" + "\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" + "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" + "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" + - "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3" + "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12T\n" + + "\x0fRespondApproval\x12\x1e.daemon.RespondApprovalRequest\x1a\x1f.daemon.RespondApprovalResponse\"\x00B\bZ\x06/protob\x06proto3" var ( file_daemon_proto_rawDescOnce sync.Once @@ -7001,7 +7144,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 99) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 101) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -7103,18 +7246,20 @@ var file_daemon_proto_goTypes = []any{ (*StartBundleCaptureResponse)(nil), // 97: daemon.StartBundleCaptureResponse (*StopBundleCaptureRequest)(nil), // 98: daemon.StopBundleCaptureRequest (*StopBundleCaptureResponse)(nil), // 99: daemon.StopBundleCaptureResponse - nil, // 100: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 101: daemon.PortInfo.Range - nil, // 102: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 103: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 104: google.protobuf.Timestamp + (*RespondApprovalRequest)(nil), // 100: daemon.RespondApprovalRequest + (*RespondApprovalResponse)(nil), // 101: daemon.RespondApprovalResponse + nil, // 102: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 103: daemon.PortInfo.Range + nil, // 104: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 105: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 106: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 103, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 105, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 27, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 104, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 104, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 103, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 106, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 106, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 105, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 25, // 6: daemon.VNCServerState.sessions:type_name -> daemon.VNCSessionInfo 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState @@ -7127,8 +7272,8 @@ var file_daemon_proto_depIdxs = []int32{ 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState 26, // 15: daemon.FullStatus.vncServerState:type_name -> daemon.VNCServerState 33, // 16: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 100, // 17: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 101, // 18: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 102, // 17: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 103, // 18: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range 34, // 19: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo 34, // 20: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo 35, // 21: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule @@ -7139,15 +7284,15 @@ var file_daemon_proto_depIdxs = []int32{ 54, // 26: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 2, // 27: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 28: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 104, // 29: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 102, // 30: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 106, // 29: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 104, // 30: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry 57, // 31: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 103, // 32: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 105, // 32: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 70, // 33: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol 93, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 103, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 103, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 105, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 105, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration 32, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest @@ -7188,47 +7333,49 @@ var file_daemon_proto_depIdxs = []int32{ 87, // 75: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest 89, // 76: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest 91, // 77: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 78: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 79: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 80: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 81: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 82: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 83: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 29, // 84: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 31, // 85: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 31, // 86: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 36, // 87: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 38, // 88: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 40, // 89: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 42, // 90: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 45, // 91: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 47, // 92: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 49, // 93: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 51, // 94: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 55, // 95: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 95, // 96: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 97, // 97: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 99, // 98: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 57, // 99: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 59, // 100: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 61, // 101: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 63, // 102: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 65, // 103: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 78, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 80, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 82, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 84, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 86, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 88, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 90, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 92, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 78, // [78:117] is the sub-list for method output_type - 39, // [39:78] is the sub-list for method input_type + 100, // 78: daemon.DaemonService.RespondApproval:input_type -> daemon.RespondApprovalRequest + 6, // 79: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 80: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 81: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 82: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 83: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 84: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 29, // 85: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 31, // 86: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 31, // 87: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 36, // 88: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 38, // 89: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 40, // 90: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 42, // 91: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 45, // 92: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 47, // 93: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 49, // 94: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 51, // 95: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 55, // 96: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 95, // 97: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 97, // 98: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 99, // 99: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 57, // 100: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 59, // 101: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 61, // 102: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 63, // 103: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 65, // 104: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 67, // 105: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 69, // 106: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 72, // 107: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 74, // 108: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 76, // 109: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 78, // 110: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 80, // 111: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 82, // 112: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 84, // 113: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 86, // 114: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 88, // 115: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 90, // 116: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 92, // 117: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 101, // 118: daemon.DaemonService.RespondApproval:output_type -> daemon.RespondApprovalResponse + 79, // [79:119] is the sub-list for method output_type + 39, // [39:79] is the sub-list for method input_type 39, // [39:39] is the sub-list for extension type_name 39, // [39:39] is the sub-list for extension extendee 0, // [0:39] is the sub-list for field type_name @@ -7261,7 +7408,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 99, + NumMessages: 101, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index d0e72021061..9592e3a753f 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -119,6 +119,14 @@ service DaemonService { // ExposeService exposes a local port via the NetBird reverse proxy rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {} + + // RespondApproval delivers the user's accept/deny decision for a + // pending user-approval prompt. The daemon pushes the prompt as a + // SystemEvent with category APPROVAL and metadata key "request_id"; + // the UI calls this RPC with the same request_id to unblock whichever + // subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells + // the UI which subsystem the prompt belongs to. + rpc RespondApproval(RespondApprovalRequest) returns (RespondApprovalResponse) {} } @@ -207,6 +215,8 @@ message LoginRequest { optional bool disable_ipv6 = 40; optional bool serverVNCAllowed = 41; + + optional bool disableVNCApproval = 42; } message LoginResponse { @@ -318,6 +328,8 @@ message GetConfigResponse { bool disable_ipv6 = 27; bool serverVNCAllowed = 28; + + bool disableVNCApproval = 29; } // PeerState contains the latest state of a peer @@ -616,6 +628,7 @@ message SystemEvent { AUTHENTICATION = 2; CONNECTIVITY = 3; SYSTEM = 4; + APPROVAL = 5; } string id = 1; @@ -701,6 +714,8 @@ message SetConfigRequest { optional bool disable_ipv6 = 35; optional bool serverVNCAllowed = 36; + + optional bool disableVNCApproval = 37; } message SetConfigResponse{} @@ -895,3 +910,18 @@ message StartBundleCaptureRequest { message StartBundleCaptureResponse {} message StopBundleCaptureRequest {} message StopBundleCaptureResponse {} + +message RespondApprovalRequest { + // request_id matches the SystemEvent metadata key emitted by the daemon + // when a subsystem awaits user approval for an inbound connection. + string request_id = 1; + // accept is true if the user approved the request, false if they + // denied it. A missing or unknown request_id is treated as a no-op. + bool accept = 2; + // view_only signals that the user granted the connection but withheld + // input control. Only meaningful when accept is true; ignored when + // accept is false. + bool view_only = 3; +} + +message RespondApprovalResponse {} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 66a8efcc325..8a11948ab62 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -58,6 +58,7 @@ const ( DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile" DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult" DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService" + DaemonService_RespondApproval_FullMethodName = "/daemon.DaemonService/RespondApproval" ) // DaemonServiceClient is the client API for DaemonService service. @@ -134,6 +135,13 @@ type DaemonServiceClient interface { GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) + // RespondApproval delivers the user's accept/deny decision for a + // pending user-approval prompt. The daemon pushes the prompt as a + // SystemEvent with category APPROVAL and metadata key "request_id"; + // the UI calls this RPC with the same request_id to unblock whichever + // subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells + // the UI which subsystem the prompt belongs to. + RespondApproval(ctx context.Context, in *RespondApprovalRequest, opts ...grpc.CallOption) (*RespondApprovalResponse, error) } type daemonServiceClient struct { @@ -561,6 +569,16 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent] +func (c *daemonServiceClient) RespondApproval(ctx context.Context, in *RespondApprovalRequest, opts ...grpc.CallOption) (*RespondApprovalResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RespondApprovalResponse) + err := c.cc.Invoke(ctx, DaemonService_RespondApproval_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility. @@ -635,6 +653,13 @@ type DaemonServiceServer interface { GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error + // RespondApproval delivers the user's accept/deny decision for a + // pending user-approval prompt. The daemon pushes the prompt as a + // SystemEvent with category APPROVAL and metadata key "request_id"; + // the UI calls this RPC with the same request_id to unblock whichever + // subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells + // the UI which subsystem the prompt belongs to. + RespondApproval(context.Context, *RespondApprovalRequest) (*RespondApprovalResponse, error) mustEmbedUnimplementedDaemonServiceServer() } @@ -762,6 +787,9 @@ func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *Ins func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error { return status.Error(codes.Unimplemented, "method ExposeService not implemented") } +func (UnimplementedDaemonServiceServer) RespondApproval(context.Context, *RespondApprovalRequest) (*RespondApprovalResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RespondApproval not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} @@ -1464,6 +1492,24 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent] +func _DaemonService_RespondApproval_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RespondApprovalRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RespondApproval(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RespondApproval_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RespondApproval(ctx, req.(*RespondApprovalRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1615,6 +1661,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetInstallerResult", Handler: _DaemonService_GetInstallerResult_Handler, }, + { + MethodName: "RespondApproval", + Handler: _DaemonService_RespondApproval_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/client/server/server.go b/client/server/server.go index ac6b142874c..1784c91a897 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -377,6 +377,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed config.ServerVNCAllowed = msg.ServerVNCAllowed + config.DisableVNCApproval = msg.DisableVNCApproval config.NetworkMonitor = msg.NetworkMonitor config.DisableClientRoutes = msg.DisableClientRoutes config.DisableServerRoutes = msg.DisableServerRoutes @@ -1448,6 +1449,27 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon return nil } +// RespondApproval relays the user's accept/deny decision for a pending +// approval prompt to the engine's broker. Unknown or already-resolved +// request_ids are silently no-op'd so a slow UI cannot deny a prompt the +// user already handled (or that already timed out). +func (s *Server) RespondApproval(_ context.Context, msg *proto.RespondApprovalRequest) (*proto.RespondApprovalResponse, error) { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + if connectClient == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client not initialized") + } + engine := connectClient.Engine() + if engine == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "engine not running") + } + if !engine.RespondApproval(msg.GetRequestId(), msg.GetAccept(), msg.GetViewOnly()) { + log.Debugf("approval response for unknown request_id %s", msg.GetRequestId()) + } + return &proto.RespondApprovalResponse{}, nil +} + func isUnixRunningDesktop() bool { if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { return false @@ -1565,6 +1587,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p DisableAutoConnect: cfg.DisableAutoConnect, ServerSSHAllowed: *cfg.ServerSSHAllowed, ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed, + DisableVNCApproval: cfg.DisableVNCApproval != nil && *cfg.DisableVNCApproval, RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, LazyConnectionEnabled: cfg.LazyConnectionEnabled, diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 01dbbed5afc..8246c3243e4 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -59,6 +59,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { rosenpassPermissive := true serverSSHAllowed := true serverVNCAllowed := true + disableVNCApproval := true interfaceName := "utun100" wireguardPort := int64(51820) preSharedKey := "test-psk" @@ -85,6 +86,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { RosenpassPermissive: &rosenpassPermissive, ServerSSHAllowed: &serverSSHAllowed, ServerVNCAllowed: &serverVNCAllowed, + DisableVNCApproval: &disableVNCApproval, InterfaceName: &interfaceName, WireguardPort: &wireguardPort, OptionalPreSharedKey: &preSharedKey, @@ -131,6 +133,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed) require.NotNil(t, cfg.ServerVNCAllowed) require.Equal(t, serverVNCAllowed, *cfg.ServerVNCAllowed) + require.NotNil(t, cfg.DisableVNCApproval) + require.Equal(t, disableVNCApproval, *cfg.DisableVNCApproval) require.Equal(t, interfaceName, cfg.WgIface) require.Equal(t, int(wireguardPort), cfg.WgPort) require.Equal(t, preSharedKey, cfg.PreSharedKey) @@ -184,6 +188,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "RosenpassPermissive": true, "ServerSSHAllowed": true, "ServerVNCAllowed": true, + "DisableVNCApproval": true, "InterfaceName": true, "WireguardPort": true, "OptionalPreSharedKey": true, @@ -246,6 +251,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", "allow-server-vnc": "ServerVNCAllowed", + "disable-vnc-approval": "DisableVNCApproval", "interface-name": "InterfaceName", "wireguard-port": "WireguardPort", "preshared-key": "OptionalPreSharedKey", diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go index b33d5f8f4bc..02cd1d58c91 100644 --- a/client/ssh/proxy/proxy_test.go +++ b/client/ssh/proxy/proxy_test.go @@ -28,7 +28,7 @@ import ( "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/client/ssh/server" "github.com/netbirdio/netbird/client/ssh/testutil" nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" diff --git a/client/ssh/server/jwt_test.go b/client/ssh/server/jwt_test.go index b2f3ac6a070..def3658c9e4 100644 --- a/client/ssh/server/jwt_test.go +++ b/client/ssh/server/jwt_test.go @@ -23,7 +23,7 @@ import ( "github.com/stretchr/testify/require" nbssh "github.com/netbirdio/netbird/client/ssh" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/client/ssh/client" "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/client/ssh/testutil" diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 3d55de6dc39..499743c66af 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -23,7 +23,7 @@ import ( "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/auth/jwt" diff --git a/client/ui/approval.go b/client/ui/approval.go new file mode 100644 index 00000000000..9e5beaf5cca --- /dev/null +++ b/client/ui/approval.go @@ -0,0 +1,192 @@ +//go:build !(linux && 386) + +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// handleApprovalEvent forks a netbird-ui child process to render the +// dialog on its own fyne main loop. Top-level windows opened from a +// background goroutine of the tray process don't render reliably on +// Linux/GTK, so the rest of the UI (settings, login URL, update) uses +// the same fork pattern. +func (s *serviceClient) handleApprovalEvent(ev *proto.SystemEvent) { + if ev == nil || ev.Category != proto.SystemEvent_APPROVAL { + return + } + requestID := ev.Metadata["request_id"] + if requestID == "" { + log.Warnf("approval event missing request_id: %v", ev.Metadata) + return + } + args := []string{ + "--approval-request-id=" + requestID, + "--approval-kind=" + ev.Metadata["kind"], + "--approval-initiator=" + ev.Metadata["initiator"], + "--approval-peer-name=" + ev.Metadata["peer_name"], + "--approval-source-ip=" + ev.Metadata["source_ip"], + "--approval-username=" + ev.Metadata["username"], + "--approval-expires-at=" + ev.Metadata["expires_at"], + "--approval-subject=" + ev.UserMessage, + } + go s.eventHandler.runSelfCommand(s.ctx, "approval", args...) +} + +// showApprovalUI runs the dialog on the forked process's fyne main loop +// and forwards the user's decision to the daemon via RespondApproval. +func (s *serviceClient) showApprovalUI(req approvalRequest) { + w := s.app.NewWindow(approvalTitle(req.kind)) + w.Resize(fyne.NewSize(480, 260)) + w.CenterOnScreen() + w.RequestFocus() + + var rows []string + if req.initiator != "" { + rows = append(rows, "From user: "+req.initiator) + } + if req.peerName != "" { + rows = append(rows, "Via peer: "+req.peerName) + } + if req.sourceIP != "" && req.sourceIP != req.peerName { + rows = append(rows, "Source IP: "+req.sourceIP) + } + if req.username != "" { + rows = append(rows, "OS user: "+req.username) + } + if len(rows) == 0 { + rows = []string{"Remote: " + req.displayPeer()} + } + body := strings.Join(rows, "\n") + bodyLabel := widget.NewLabel(body) + bodyLabel.Wrapping = fyne.TextWrapWord + + countdown := widget.NewLabel("") + deadline := req.deadline() + updateCountdown := func() { + remaining := time.Until(deadline).Round(time.Second) + if remaining < 0 { + remaining = 0 + } + countdown.SetText(fmt.Sprintf("Auto-deny in %s", remaining)) + } + updateCountdown() + + type outcome struct { + accept bool + viewOnly bool + } + decided := make(chan outcome, 1) + decide := func(o outcome) { + select { + case decided <- o: + default: + } + } + + allow := widget.NewButton("Allow", func() { decide(outcome{accept: true}) }) + allow.Importance = widget.HighImportance + allowView := widget.NewButton("Allow (view only)", func() { decide(outcome{accept: true, viewOnly: true}) }) + deny := widget.NewButton("Deny", func() { decide(outcome{accept: false}) }) + + header := widget.NewLabelWithStyle(req.subject, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + buttonRow := container.NewGridWithColumns(3, allow, allowView, deny) + info := container.NewVBox(header, widget.NewSeparator(), bodyLabel, widget.NewSeparator(), countdown) + w.SetContent(container.NewPadded(container.NewBorder(nil, buttonRow, nil, nil, info))) + w.SetCloseIntercept(func() { decide(outcome{}) }) + + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for range ticker.C { + if time.Until(deadline) <= 0 { + decide(outcome{}) + return + } + updateCountdown() + } + }() + + go func() { + o := <-decided + s.sendApprovalResponse(req.requestID, o.accept, o.viewOnly) + w.Close() + s.app.Quit() + }() + + w.Show() +} + +func (s *serviceClient) sendApprovalResponse(requestID string, accept, viewOnly bool) { + conn, err := s.getSrvClient(defaultFailTimeout) + if err != nil { + log.Warnf("approval response: get daemon client: %v", err) + return + } + ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) + defer cancel() + if _, err := conn.RespondApproval(ctx, &proto.RespondApprovalRequest{ + RequestId: requestID, + Accept: accept, + ViewOnly: viewOnly, + }); err != nil { + log.Warnf("approval response: %v", err) + } +} + +// approvalRequest is the parsed --approval-* CLI args that the forked +// dialog process consumes. +type approvalRequest struct { + requestID string + kind string + initiator string + peerName string + sourceIP string + username string + subject string + expiresAt string +} + +func (r approvalRequest) displayPeer() string { + switch { + case r.initiator != "": + return r.initiator + case r.peerName != "": + return r.peerName + case r.sourceIP != "": + return r.sourceIP + default: + return "unknown peer" + } +} + +// deadline returns the wall-clock auto-deny moment. Falls back to a short +// local window when the daemon's expires_at is missing/unparseable, so a +// stale value never leaves the dialog open indefinitely. +func (r approvalRequest) deadline() time.Time { + if t, err := time.Parse(time.RFC3339, r.expiresAt); err == nil { + return t + } + return time.Now().Add(13 * time.Second) +} + +func approvalTitle(kind string) string { + switch kind { + case "vnc": + return "Allow VNC Connection?" + case "ssh": + return "Allow SSH Connection?" + default: + return "Allow Incoming Connection?" + } +} diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 863e8b291c5..f05e8dceb1a 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -97,13 +97,24 @@ func main() { showQuickActions: flags.showQuickActions, showUpdate: flags.showUpdate, showUpdateVersion: flags.showUpdateVersion, + showApproval: flags.showApproval, + approvalRequest: approvalRequest{ + requestID: flags.approvalRequestID, + kind: flags.approvalKind, + initiator: flags.approvalInitiator, + peerName: flags.approvalPeerName, + sourceIP: flags.approvalSourceIP, + username: flags.approvalUsername, + subject: flags.approvalSubject, + expiresAt: flags.approvalExpiresAt, + }, }) // Watch for theme/settings changes to update the icon. go watchSettingsChanges(a, client) // Run in window mode if any UI flag was set. - if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate { + if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate || flags.showApproval { a.Run() return } @@ -140,6 +151,16 @@ type cliFlags struct { saveLogsInFile bool showUpdate bool showUpdateVersion string + showApproval bool + + approvalRequestID string + approvalKind string + approvalInitiator string + approvalPeerName string + approvalSourceIP string + approvalUsername string + approvalSubject string + approvalExpiresAt string } // parseFlags reads and returns all needed command-line flags. @@ -161,6 +182,15 @@ func parseFlags() *cliFlags { flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window") flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window") flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to") + flag.BoolVar(&flags.showApproval, "approval", false, "show inbound-connection approval prompt window") + flag.StringVar(&flags.approvalRequestID, "approval-request-id", "", "approval prompt: daemon-issued request id") + flag.StringVar(&flags.approvalKind, "approval-kind", "", "approval prompt: subsystem kind (vnc, ssh, ...)") + flag.StringVar(&flags.approvalInitiator, "approval-initiator", "", "approval prompt: display name of the user who initiated the connection") + flag.StringVar(&flags.approvalPeerName, "approval-peer-name", "", "approval prompt: remote peer FQDN") + flag.StringVar(&flags.approvalSourceIP, "approval-source-ip", "", "approval prompt: remote source IP") + flag.StringVar(&flags.approvalUsername, "approval-username", "", "approval prompt: requested OS username") + flag.StringVar(&flags.approvalSubject, "approval-subject", "", "approval prompt: human-readable subject line") + flag.StringVar(&flags.approvalExpiresAt, "approval-expires-at", "", "approval prompt: RFC3339 deadline at which the daemon auto-denies") flag.Parse() return &flags } @@ -288,6 +318,8 @@ type serviceClient struct { sEnableSSHRemotePortForward *widget.Check sDisableSSHAuth *widget.Check iSSHJWTCacheTTL *widget.Entry + sServerVNCAllowed *widget.Check + sDisableVNCApproval *widget.Check // observable settings over corresponding iMngURL and iPreSharedKey values. managementURL string @@ -309,6 +341,8 @@ type serviceClient struct { enableSSHRemotePortForward bool disableSSHAuth bool sshJWTCacheTTL int + serverVNCAllowed bool + disableVNCApproval bool connected bool daemonVersion string @@ -356,6 +390,8 @@ type newServiceClientArgs struct { showQuickActions bool showUpdate bool showUpdateVersion string + showApproval bool + approvalRequest approvalRequest } // newServiceClient instance constructor @@ -396,6 +432,8 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient { s.showQuickActionsUI() case args.showUpdate: s.showUpdateProgress(ctx, args.showUpdateVersion) + case args.showApproval: + s.showApprovalUI(args.approvalRequest) } return s @@ -479,6 +517,8 @@ func (s *serviceClient) showSettingsUI() { s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil) s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil) s.iSSHJWTCacheTTL = widget.NewEntry() + s.sServerVNCAllowed = widget.NewCheck("Allow embedded VNC server on this peer", nil) + s.sDisableVNCApproval = widget.NewCheck("Skip per-connection approval prompt for VNC", nil) s.wSettings.SetContent(s.getSettingsForm()) s.wSettings.Resize(fyne.NewSize(600, 400)) @@ -591,7 +631,8 @@ func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool s.disableServerRoutes != s.sDisableServerRoutes.Checked || s.disableIPv6 != s.sDisableIPv6.Checked || s.blockLANAccess != s.sBlockLANAccess.Checked || - s.hasSSHChanges() + s.hasSSHChanges() || + s.hasVNCChanges() } func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error { @@ -650,6 +691,8 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) ( req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked req.DisableSSHAuth = &s.sDisableSSHAuth.Checked + req.ServerVNCAllowed = &s.sServerVNCAllowed.Checked + req.DisableVNCApproval = &s.sDisableVNCApproval.Checked sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text) if sshJWTCacheTTLText != "" { @@ -710,10 +753,12 @@ func (s *serviceClient) getSettingsForm() fyne.CanvasObject { connectionForm := s.getConnectionForm() networkForm := s.getNetworkForm() sshForm := s.getSSHForm() + vncForm := s.getVNCForm() tabs := container.NewAppTabs( container.NewTabItem("Connection", connectionForm), container.NewTabItem("Network", networkForm), container.NewTabItem("SSH", sshForm), + container.NewTabItem("VNC", vncForm), ) saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings) saveButton.Importance = widget.HighImportance @@ -754,6 +799,15 @@ func (s *serviceClient) getSSHForm() *widget.Form { } } +func (s *serviceClient) getVNCForm() *widget.Form { + return &widget.Form{ + Items: []*widget.FormItem{ + {Text: "Allow VNC Server", Widget: s.sServerVNCAllowed}, + {Text: "Disable Connection Approval Prompt", Widget: s.sDisableVNCApproval}, + }, + } +} + func (s *serviceClient) hasSSHChanges() bool { currentSSHJWTCacheTTL := s.sshJWTCacheTTL if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" { @@ -772,6 +826,11 @@ func (s *serviceClient) hasSSHChanges() bool { s.sshJWTCacheTTL != currentSSHJWTCacheTTL } +func (s *serviceClient) hasVNCChanges() bool { + return s.serverVNCAllowed != s.sServerVNCAllowed.Checked || + s.disableVNCApproval != s.sDisableVNCApproval.Checked +} + func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { @@ -1120,6 +1179,7 @@ func (s *serviceClient) onTrayReady() { s.eventManager = event.NewManager(s.notifier, s.addr) s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) + s.eventManager.AddHandler(s.handleApprovalEvent) s.eventManager.AddHandler(func(event *proto.SystemEvent) { if event.Category == proto.SystemEvent_SYSTEM { s.updateExitNodes() @@ -1355,6 +1415,12 @@ func (s *serviceClient) getSrvConfig() { if cfg.SSHJWTCacheTTL != nil { s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL } + if cfg.ServerVNCAllowed != nil { + s.serverVNCAllowed = *cfg.ServerVNCAllowed + } + if cfg.DisableVNCApproval != nil { + s.disableVNCApproval = *cfg.DisableVNCApproval + } if s.showAdvancedSettings { s.iMngURL.SetText(s.managementURL) @@ -1395,6 +1461,12 @@ func (s *serviceClient) getSrvConfig() { if cfg.SSHJWTCacheTTL != nil { s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL)) } + if cfg.ServerVNCAllowed != nil { + s.sServerVNCAllowed.SetChecked(*cfg.ServerVNCAllowed) + } + if cfg.DisableVNCApproval != nil { + s.sDisableVNCApproval.SetChecked(*cfg.DisableVNCApproval) + } } if s.mNotifications == nil { @@ -1455,6 +1527,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { config.DisableAutoConnect = cfg.DisableAutoConnect config.ServerSSHAllowed = &cfg.ServerSSHAllowed config.ServerVNCAllowed = &cfg.ServerVNCAllowed + config.DisableVNCApproval = &cfg.DisableVNCApproval config.RosenpassEnabled = cfg.RosenpassEnabled config.RosenpassPermissive = cfg.RosenpassPermissive config.DisableNotifications = &cfg.DisableNotifications diff --git a/client/ui/event/event.go b/client/ui/event/event.go index 3b43fdc7f23..06784d2dec4 100644 --- a/client/ui/event/event.go +++ b/client/ui/event/event.go @@ -112,7 +112,7 @@ func (e *Manager) handleEvent(event *proto.SystemEvent) { handlers := slices.Clone(e.handlers) e.mu.Unlock() - if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) && !isV6DefaultRoutePartner(event) { + if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) && !isV6DefaultRoutePartner(event) && event.Category != proto.SystemEvent_APPROVAL { title := e.getEventTitle(event) body := event.UserMessage id := event.Metadata["id"] diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index a9ef3a77ac7..dc2e96a4765 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -72,6 +72,11 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { } s.registerConnAuth(conn, header) + allow, decision := s.gateApproval(conn, header, authedLog) + if !allow { + return + } + socketPath, token, err := sa.Resolve(s.ctx) if err != nil { code := RejectCodeCapturerError @@ -87,7 +92,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { Reader: io.MultiReader(&headerBuf, conn), Conn: conn, } - if err := proxyToAgent(s.ctx, replayConn, socketPath, token); err != nil { + if err := proxyToAgent(s.ctx, replayConn, socketPath, token, decision.ViewOnly); err != nil { rejectConnection(conn, codeMessage(RejectCodeCapturerError, err.Error())) authedLog.Warnf("VNC connection rejected: agent unreachable: %v", err) return @@ -124,12 +129,13 @@ func generateAuthToken() (string, error) { } // proxyToAgent dials the per-session agent's Unix socket, writes the -// raw token bytes, then copies bytes both ways until either side closes. -// The token must precede any RFB byte so the agent's verifyAgentToken -// can run first. Returns nil once a stream is established; the caller is -// responsible for sending an RFB-level rejection on error so the client -// sees a reason instead of a bare timeout. -func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken string) error { +// raw token bytes plus a single view-only flag byte, then copies bytes +// both ways until either side closes. The token + flag prefix must +// precede any RFB byte so the agent's verifyAgentToken can run first. +// Returns nil once a stream is established; the caller is responsible +// for sending an RFB-level rejection on error so the client sees a +// reason instead of a bare timeout. +func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken string, viewOnly bool) error { tokenBytes, err := hex.DecodeString(authToken) if err != nil || len(tokenBytes) != agentTokenLen { return fmt.Errorf("invalid auth token (len=%d): %w", len(tokenBytes), err) @@ -140,9 +146,14 @@ func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken st return fmt.Errorf("dial agent at %s: %w", socketPath, err) } - if _, err := agentConn.Write(tokenBytes); err != nil { + preamble := make([]byte, len(tokenBytes)+1) + copy(preamble, tokenBytes) + if viewOnly { + preamble[len(tokenBytes)] = 1 + } + if _, err := agentConn.Write(preamble); err != nil { _ = agentConn.Close() - return fmt.Errorf("send auth token to agent: %w", err) + return fmt.Errorf("send auth preamble to agent: %w", err) } defer client.Close() diff --git a/client/vnc/server/noise_auth_test.go b/client/vnc/server/noise_auth_test.go index 34ec054904c..08eae08393b 100644 --- a/client/vnc/server/noise_auth_test.go +++ b/client/vnc/server/noise_auth_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/curve25519" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index eb384dcf2af..d350be70f0a 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -23,7 +23,7 @@ import ( "golang.org/x/crypto/curve25519" "golang.zx2c4.com/wireguard/tun/netstack" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" ) // Connection modes sent by the client in the session header. @@ -36,12 +36,14 @@ const ( // stable so clients can branch on them without parsing free text. // Format: "CODE: human message". const ( - RejectCodeAuthForbidden = "AUTH_FORBIDDEN" - RejectCodeSessionError = "SESSION_ERROR" - RejectCodeCapturerError = "CAPTURER_ERROR" - RejectCodeUnsupportedOS = "UNSUPPORTED" - RejectCodeBadRequest = "BAD_REQUEST" - RejectCodeNoConsoleUser = "NO_CONSOLE_USER" + RejectCodeAuthForbidden = "AUTH_FORBIDDEN" + RejectCodeSessionError = "SESSION_ERROR" + RejectCodeCapturerError = "CAPTURER_ERROR" + RejectCodeUnsupportedOS = "UNSUPPORTED" + RejectCodeBadRequest = "BAD_REQUEST" + RejectCodeNoConsoleUser = "NO_CONSOLE_USER" + RejectCodeApprovalDenied = "APPROVAL_DENIED" + RejectCodeNoApprover = "NO_APPROVER" ) // EnvVNCDisableDownscale disables any platform-specific framebuffer @@ -173,11 +175,11 @@ type Server struct { network netip.Prefix log *log.Entry - mu sync.Mutex - listener net.Listener - ctx context.Context - cancel context.CancelFunc - vmgr virtualSessionManager + mu sync.Mutex + listener net.Listener + ctx context.Context + cancel context.CancelFunc + vmgr virtualSessionManager authorizer *sshauth.Authorizer netstackNet *netstack.Net // agentToken holds the raw token bytes for agent-mode auth. @@ -216,6 +218,14 @@ type Server struct { // this to its metrics framework. sessionRecorder func(SessionTick) + // requireApproval enables the per-connection user-accept gate. When + // true and approver is nil (or returns an error), the connection is + // rejected before any agent or session work. + requireApproval bool + // approver prompts the local user (via the daemon→UI event channel) + // to accept or deny each incoming connection. + approver Approver + // preListener, when non-nil, replaces the TCP listener Start would // open; addr/network args to Start are ignored. Used by the agent's // Unix-socket path. @@ -275,6 +285,40 @@ type Config struct { // addr/network args to Start are then ignored. The agent uses this to // listen on a Unix socket. Listener net.Listener + // RequireApproval gates each accepted connection on a user-side accept + // prompt before the proxy/session starts. Requires Approver to be set; + // otherwise the gate fails closed. + RequireApproval bool + // Approver brokers the per-connection prompt to the local user via the + // daemon→UI event channel. Nil disables the gate. + Approver Approver +} + +// Approver decouples the VNC server from the approval broker. A non-nil +// error means "do not proceed". +type Approver interface { + Request(ctx context.Context, info ApprovalInfo) (ApprovalDecision, error) +} + +// ApprovalDecision carries the parts of the user's response the VNC +// server acts on. Accept is implicit (errors signal deny). ViewOnly puts +// the session into read-only mode: the server drops input events. +type ApprovalDecision struct { + ViewOnly bool +} + +// ApprovalInfo describes the pending connection passed to the approver. +// Fields are best-effort; any may be empty. +type ApprovalInfo struct { + PeerName string + PeerPubKey string + SourceIP string + Mode string + Username string + // Initiator is the display name of the user who initiated the + // connection (typically the dashboard user). Resolved from the + // Noise-verified client static pubkey. + Initiator string } // New creates a VNC server from the provided Config. IdentityKey is the @@ -287,6 +331,8 @@ func New(cfg Config) *Server { identityKey: cfg.IdentityKey, serviceMode: cfg.ServiceMode, sessionRecorder: cfg.SessionRecorder, + requireApproval: cfg.RequireApproval, + approver: cfg.Approver, disableAuth: cfg.DisableAuth, netstackNet: cfg.NetstackNet, preListener: cfg.Listener, @@ -377,6 +423,59 @@ func (s *Server) untrackConn(c net.Conn) { s.sessionsMu.Unlock() } +// gateApproval prompts the local user to accept or deny conn before any +// session resources are allocated. On rejection the conn already received +// an RFB reject reason; the gate does not close it. +func (s *Server) gateApproval(conn net.Conn, header *connectionHeader, connLog *log.Entry) (bool, ApprovalDecision) { + if !s.requireApproval { + return true, ApprovalDecision{} + } + if s.approver == nil { + rejectConnection(conn, codeMessage(RejectCodeNoApprover, "approval required but no approver configured")) + connLog.Warn("VNC connection rejected: approval required but no approver") + return false, ApprovalDecision{} + } + info := ApprovalInfo{ + SourceIP: sourceIPString(conn.RemoteAddr()), + Mode: modeString(header.mode), + Username: header.username, + } + if len(header.clientStatic) == 32 { + info.PeerPubKey = hex.EncodeToString(header.clientStatic) + if s.authorizer != nil { + info.Initiator = s.authorizer.LookupSessionDisplayName(header.clientStatic) + } + } + decision, err := s.approver.Request(s.ctx, info) + if err != nil { + rejectConnection(conn, codeMessage(RejectCodeApprovalDenied, err.Error())) + connLog.Infof("VNC connection rejected: approval %v", err) + return false, ApprovalDecision{} + } + if decision.ViewOnly { + connLog.Info("VNC connection approved by user (view-only)") + } else { + connLog.Info("VNC connection approved by user") + } + return true, decision +} + +// sourceIPString returns the IP portion of a remote address, or the full +// string when no port is present (e.g. unix sockets). +func sourceIPString(addr net.Addr) string { + if addr == nil { + return "" + } + if ta, ok := addr.(*net.TCPAddr); ok && ta != nil { + return ta.IP.String() + } + host, _, err := net.SplitHostPort(addr.String()) + if err != nil { + return addr.String() + } + return host +} + // registerConnAuth records the verified Noise_IK identity for a live // connection so UpdateVNCAuth can later revoke it if policy changes. // No-op when auth is disabled (e.g. agent-mode loopback connections). @@ -673,7 +772,8 @@ func (s *Server) handleConnection(conn net.Conn) { _ = conn.Close() return } - if !s.verifyAgentToken(conn, connLog) { + ok, agentViewOnly := s.verifyAgentToken(conn, connLog) + if !ok { connLog.Info("VNC connection rejected: agent token check failed") return } @@ -683,13 +783,19 @@ func (s *Server) handleConnection(conn net.Conn) { _ = conn.Close() return } - connLog, sessionUserID, ok := s.authorizeSession(conn, header, connLog) + var sessionUserID string + connLog, sessionUserID, ok = s.authorizeSession(conn, header, connLog) if !ok { connLog.Info("VNC connection rejected: auth failed") return } s.registerConnAuth(conn, header) + allow, decision := s.gateApproval(conn, header, connLog) + if !allow { + return + } + capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog) if !ok { connLog.Warn("VNC connection rejected: capturer/injector unavailable") @@ -726,6 +832,7 @@ func (s *Server) handleConnection(conn net.Conn) { serverW: w, serverH: h, log: connLog, + viewOnly: decision.ViewOnly || agentViewOnly, } sess.serve() connLog.Infof("VNC connection closed (%dms)", time.Since(start).Milliseconds()) @@ -791,8 +898,9 @@ func (s *Server) authenticateSession(header *connectionHeader) (string, error) { var vncIdentityMagic = []byte("NBV3") // Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads). -// msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes -// msg2 = e(32) + payload_AEAD(0+16) = 48 bytes +// +// msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes +// msg2 = e(32) + payload_AEAD(0+16) = 48 bytes const ( noiseInitiatorMsgLen = 96 noiseResponderMsgLen = 48 @@ -929,39 +1037,40 @@ func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte return clientStatic, true, nil } -// verifyAgentToken validates the agent token prefix when configured. Returns -// false when the token is invalid or unreadable; the connection is closed. -func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool { +// verifyAgentToken validates the agent token prefix when configured and +// reads the trailing view-only flag byte the daemon writes alongside it. +// Returns (ok, viewOnly). ok=false closes the connection. +func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) (bool, bool) { if len(s.agentToken) == 0 { - return true + return true, false } - buf := make([]byte, len(s.agentToken)) + buf := make([]byte, len(s.agentToken)+1) if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { connLog.Debugf("set agent token deadline: %v", err) conn.Close() - return false + return false, false } if _, err := io.ReadFull(conn, buf); err != nil { if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { // Connect-then-close probes (port liveness checks) hit this // path on every dial; logging them would just flood the // daemon log without surfacing a real failure. - connLog.Tracef("agent auth: read token: %v", err) + connLog.Tracef("agent auth: read preamble: %v", err) } else { - connLog.Warnf("agent auth: read token: %v", err) + connLog.Warnf("agent auth: read preamble: %v", err) } conn.Close() - return false + return false, false } if err := conn.SetReadDeadline(time.Time{}); err != nil { connLog.Debugf("clear agent token deadline: %v", err) } - if subtle.ConstantTimeCompare(buf, s.agentToken) != 1 { + if subtle.ConstantTimeCompare(buf[:len(s.agentToken)], s.agentToken) != 1 { connLog.Warn("agent auth: invalid token, rejecting") conn.Close() - return false + return false, false } - return true + return true, buf[len(s.agentToken)] != 0 } // authorizeSession runs the Noise_IK handshake when auth is enabled. diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index 0a44de3e40e..a8a6dffb861 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -3,15 +3,19 @@ package server import ( + "context" "encoding/binary" "encoding/hex" + "errors" "image" "io" "net" "net/netip" + "sync/atomic" "testing" "time" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -333,3 +337,180 @@ func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(reason), RejectCodeUnsupportedOS) } + +// recordingApprover lets gate tests choose the outcome of the approval +// prompt and verify how often (and with what info) the gate calls it. +type recordingApprover struct { + calls atomic.Int32 + lastIn ApprovalInfo + decision ApprovalDecision + respond error +} + +func (r *recordingApprover) Request(_ context.Context, info ApprovalInfo) (ApprovalDecision, error) { + r.calls.Add(1) + r.lastIn = info + if r.respond != nil { + return ApprovalDecision{}, r.respond + } + return r.decision, nil +} + +// drainRejectClient simulates a remote VNC client just enough that +// rejectConnection's handshake-half completes promptly: it reads the +// server's "RFB 003.008\n", writes back a placeholder client version, and +// drains until EOF. Without this the rejectConnection path would block +// for up to two seconds on its SetReadDeadline. +func drainRejectClient(t *testing.T, c net.Conn) { + t.Helper() + go func() { + defer c.Close() + var srvVer [12]byte + if _, err := io.ReadFull(c, srvVer[:]); err != nil { + return + } + _, _ = c.Write([]byte("RFB 003.008\n")) + _, _ = io.Copy(io.Discard, c) + }() +} + +// newGateConn returns a server-side conn and a client-side conn linked by +// net.Pipe, with the client-side already draining so gateApproval's +// rejectConnection path completes without blocking the test. +func newGateConn(t *testing.T) net.Conn { + t.Helper() + srv, cli := net.Pipe() + drainRejectClient(t, cli) + t.Cleanup(func() { _ = srv.Close() }) + return srv +} + +func gateTestServer(requireApproval bool, approver Approver) *Server { + return &Server{ + log: log.WithField("test", "gate"), + requireApproval: requireApproval, + approver: approver, + } +} + +// TestGateApproval_Disabled_NoApproverCall: when the feature is off the +// gate must short-circuit before consulting any approver. A nil approver +// must NOT mean "deny" here — that would break upgrades for peers that +// haven't opted in yet. +func TestGateApproval_Disabled_NoApproverCall(t *testing.T) { + app := &recordingApprover{} + srv := gateTestServer(false, app) + + conn := newGateConn(t) + defer conn.Close() + header := &connectionHeader{mode: ModeAttach} + + allowed, _ := srv.gateApproval(conn, header, srv.log) + assert.True(t, allowed, "gate must pass through when requireApproval is false") + assert.Equal(t, int32(0), app.calls.Load(), "approver must not be called when disabled") +} + +// TestGateApproval_Enabled_NilApproverDenies is the most important +// regression test for "no silent bypass": if the feature is enabled but +// the broker wasn't wired (a misconfiguration), the gate must REJECT, +// not pass through. The reject code must be the dedicated NO_APPROVER so +// the failure is unambiguous in logs and on the client side. +func TestGateApproval_Enabled_NilApproverDenies(t *testing.T) { + srv := gateTestServer(true, nil) + + srvConn, cliConn := net.Pipe() + defer srvConn.Close() + defer cliConn.Close() + + // Capture the reject reason the gate sends. + rejectReason := make(chan string, 1) + go func() { + var srvVer [12]byte + _, _ = io.ReadFull(cliConn, srvVer[:]) + _, _ = cliConn.Write([]byte("RFB 003.008\n")) + // Server sends: 1 byte (numTypes=0), 4 bytes (reason len), reason. + var numTypes [1]byte + _, _ = io.ReadFull(cliConn, numTypes[:]) + var lenBuf [4]byte + _, _ = io.ReadFull(cliConn, lenBuf[:]) + reason := make([]byte, binary.BigEndian.Uint32(lenBuf[:])) + _, _ = io.ReadFull(cliConn, reason) + rejectReason <- string(reason) + }() + + header := &connectionHeader{mode: ModeAttach} + allowed, _ := srv.gateApproval(srvConn, header, srv.log) + assert.False(t, allowed, "missing approver MUST deny; never silently pass") + + select { + case reason := <-rejectReason: + assert.Contains(t, reason, RejectCodeNoApprover, "reject code must surface the misconfiguration cause") + case <-time.After(2 * time.Second): + t.Fatal("did not observe rejection reason") + } +} + +// TestGateApproval_ApproverDenies maps every approver error to a deny. +// We assert against every Err* the broker can produce so a future caller +// adding a new error doesn't accidentally fall into a default-allow. +func TestGateApproval_ApproverDenies(t *testing.T) { + cases := []struct { + name string + err error + }{ + {"denied", errors.New("user denied")}, + {"timeout", errors.New("approval timed out")}, + {"no_subscriber", errors.New("no UI subscriber connected for approval")}, + {"ctx_canceled", context.Canceled}, + {"misc", errors.New("anything else")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &recordingApprover{respond: tc.err} + srv := gateTestServer(true, app) + conn := newGateConn(t) + defer conn.Close() + + header := &connectionHeader{mode: ModeAttach} + allowed, _ := srv.gateApproval(conn, header, srv.log) + assert.False(t, allowed, "approver error %v must deny", tc.err) + assert.Equal(t, int32(1), app.calls.Load()) + }) + } +} + +// TestGateApproval_ApproverAccepts confirms the happy path actually +// returns true so we know the deny path is not the only outcome the +// gate can produce. +func TestGateApproval_ApproverAccepts(t *testing.T) { + app := &recordingApprover{respond: nil} + srv := gateTestServer(true, app) + conn := newGateConn(t) + defer conn.Close() + + header := &connectionHeader{mode: ModeAttach, username: "alice"} + allowed, _ := srv.gateApproval(conn, header, srv.log) + assert.True(t, allowed, "approver returning nil must let the gate pass") + assert.Equal(t, int32(1), app.calls.Load()) + assert.Equal(t, "alice", app.lastIn.Username, "header username must reach the approver") +} + +// TestGateApproval_PassesPubKeyHex confirms the gate hex-encodes the +// 32-byte client static key into ApprovalInfo.PeerPubKey so the prompt's +// metadata identifies which peer is connecting. A wrong-length key must +// NOT bypass the gate; it just won't populate the field. +func TestGateApproval_PassesPubKeyHex(t *testing.T) { + app := &recordingApprover{respond: nil} + srv := gateTestServer(true, app) + conn := newGateConn(t) + defer conn.Close() + + pub := make([]byte, 32) + for i := range pub { + pub[i] = byte(i) + } + header := &connectionHeader{mode: ModeAttach, clientStatic: pub} + allowed, _ := srv.gateApproval(conn, header, srv.log) + assert.True(t, allowed) + assert.Equal(t, hex.EncodeToString(pub), app.lastIn.PeerPubKey) +} diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 50db0265814..210e4379f3d 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -55,6 +55,11 @@ type session struct { serverH int desktopName string log *log.Entry + // viewOnly drops KeyEvent / PointerEvent (legacy + QEMU + extended) + // without invoking the injector when the user approved the + // connection in view-only mode. The bytes are still consumed off the + // wire so the protocol stays in sync. + viewOnly bool writeMu sync.Mutex // encMu guards the negotiated pixel format and encoding state below. @@ -161,6 +166,15 @@ func (s *session) serve() { } s.log.Infof("client connected: %s", s.addr()) + // View-only clients can't move the pointer, so default to compositing + // the host cursor into the framebuffer. The client can still send + // ShowRemoteCursor to turn it off. + if s.viewOnly { + s.encMu.Lock() + s.showRemoteCursor = true + s.encMu.Unlock() + } + // On any exit path (clean disconnect, transport error, panic) release // modifier keys and mouse buttons so the host doesn't end up with // Shift/Ctrl/Alt or a mouse button stuck because the client dropped @@ -226,8 +240,10 @@ func (s *session) handshake() error { // mode, username) that precedes the RFB handshake; the protocol-level // password scheme is not supported. func (s *session) sendSecurityTypes() error { - _, err := s.conn.Write([]byte{1, secNone}) - return err + if _, err := s.conn.Write([]byte{1, secNone}); err != nil { + return err + } + return nil } func (s *session) handleSecurity(secType byte) error { @@ -237,11 +253,20 @@ func (s *session) handleSecurity(secType byte) error { return binary.Write(s.conn, binary.BigEndian, uint32(0)) } +// ViewOnlyDesktopNamePrefix tags the RFB desktop name when the host +// approved the connection in view-only mode, so a NetBird-aware client +// can switch its UI into read-only state. NUL framing guarantees no +// collision with a user-set name. +const ViewOnlyDesktopNamePrefix = "\x00NB-VIEW-ONLY\x00" + func (s *session) sendServerInit() error { desktop := s.desktopName if desktop == "" { desktop = "NetBird VNC" } + if s.viewOnly { + desktop = ViewOnlyDesktopNamePrefix + desktop + } name := []byte(desktop) buf := make([]byte, 0, 4+16+4+len(name)) @@ -259,8 +284,10 @@ func (s *session) sendServerInit() error { ) buf = append(buf, name...) - _, err := s.conn.Write(buf) - return err + if _, err := s.conn.Write(buf); err != nil { + return err + } + return nil } func (s *session) messageLoop() error { @@ -536,8 +563,10 @@ func (s *session) SendDesktopName(name string) error { if _, err := s.conn.Write(header); err != nil { return err } - _, err := s.conn.Write(body) - return err + if _, err := s.conn.Write(body); err != nil { + return err + } + return nil } func (s *session) handleKeyEvent() error { @@ -545,6 +574,9 @@ func (s *session) handleKeyEvent() error { if _, err := io.ReadFull(s.conn, data[:]); err != nil { return fmt.Errorf("read KeyEvent: %w", err) } + if s.viewOnly { + return nil + } down := data[0] == 1 keysym := binary.BigEndian.Uint32(data[3:7]) s.injector.InjectKey(keysym, down) @@ -565,6 +597,9 @@ func (s *session) handleQEMUMessage() error { s.log.Tracef("ignoring QEMU subtype %d", subtype) return nil } + if s.viewOnly { + return nil + } down := binary.BigEndian.Uint16(data[1:3]) != 0 keysym := binary.BigEndian.Uint32(data[3:7]) scancode := binary.BigEndian.Uint32(data[7:11]) @@ -598,6 +633,9 @@ func (s *session) handlePointerEvent() error { s.lastPointerX = x s.lastPointerY = y s.pointerMu.Unlock() + if s.viewOnly { + return nil + } s.injector.InjectPointer(mask, x, y, s.serverW, s.serverH) return nil } diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 60f03b21287..2d57927edac 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -58,16 +58,14 @@ func NewSessionKey() (string, []byte, error) { return id, kp.Public, nil } -// consumeSessionKey atomically retrieves and removes the keypair for id. -// A session handle is single-use; combining lookup and delete under one -// critical section prevents concurrent callers from observing the same key. -func consumeSessionKey(id string) (noise.DHKey, bool) { +// lookupSessionKey returns the keypair for id. Keys stay live for the +// WASM lifetime so the same session handle can drive multiple VNC +// connections (reconnect, multiple peers, etc.). The handle is just an +// opaque map key; the private half never leaves wasm. +func lookupSessionKey(id string) (noise.DHKey, bool) { sessionKeyStore.mu.Lock() defer sessionKeyStore.mu.Unlock() kp, ok := sessionKeyStore.keys[id] - if ok { - delete(sessionKeyStore.keys, id) - } return kp, ok } @@ -187,7 +185,7 @@ func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { height: height, } if req.KeySessionID != "" { - kp, ok := consumeSessionKey(req.KeySessionID) + kp, ok := lookupSessionKey(req.KeySessionID) if !ok { return rejectedPromise("unknown VNC session id") } @@ -217,11 +215,11 @@ func decodePeerPubKey(b64 string) ([]byte, error) { return raw, nil } -// rejectedPromise returns a resolved Promise carrying msg as an error -// string, mirroring how CreateProxy reports earlier validation failures. +// rejectedPromise returns a rejected Promise carrying msg as the +// reason. Callers in JS see this via `await ...` throwing. func rejectedPromise(msg string) js.Value { promise := js.Global().Get("Promise") - return promise.Call("resolve", js.ValueOf(msg)) + return promise.Call("reject", js.ValueOf(msg)) } // newProxyPromise wraps the JS Promise creation + executor lifecycle so diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 1ecb7306bad..66c4729da5b 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -13,7 +13,7 @@ import ( integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" - "github.com/netbirdio/netbird/client/ssh/auth" + auth "github.com/netbirdio/netbird/shared/sessionauth" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" @@ -223,8 +223,9 @@ func buildSessionPubKeysProto(ctx context.Context, in []types.VNCSessionPubKey) continue } out = append(out, &proto.SessionPubKey{ - PubKey: pub, - UserIdHash: hash[:], + PubKey: pub, + UserIdHash: hash[:], + DisplayName: e.DisplayName, }) } return out diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 45ec2556c67..8e7385f7a98 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -22,6 +22,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" @@ -525,6 +526,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } policy.Rules[0].SessionPubKey = pubKey + policy.Rules[0].SessionDisplayName = h.displayNameForUser(r.Context(), userAuth) } _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true) @@ -744,3 +746,30 @@ func peerIPv6String(peer *nbpeer.Peer) *string { s := peer.IPv6.String() return &s } + +// displayNameForUser returns a human-readable label for the requesting +// user suitable for a VNC approval prompt. Tries the IdP-resolved +// UserInfo first (carries name / email management caches from the +// identity provider) and falls through to JWT claims, then user id. +// Errors from the lookup don't fail the request; we just degrade. +func (h *Handler) displayNameForUser(ctx context.Context, u auth.UserAuth) string { + if info, err := h.accountManager.GetCurrentUserInfo(ctx, u); err == nil && info != nil { + switch { + case info.UserInfo != nil && info.UserInfo.Name != "": + return info.UserInfo.Name + case info.UserInfo != nil && info.UserInfo.Email != "": + return info.UserInfo.Email + } + } else if err != nil { + log.WithContext(ctx).Debugf("display name: GetCurrentUserInfo: %v", err) + } + switch { + case u.PreferredName != "": + return u.PreferredName + case u.Name != "": + return u.Name + case u.Email != "": + return u.Email + } + return u.UserId +} diff --git a/management/server/types/account.go b/management/server/types/account.go index 5f749842e6a..8110216bee2 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -14,7 +14,7 @@ import ( "github.com/rs/xid" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/ssh/auth" + auth "github.com/netbirdio/netbird/shared/sessionauth" nbdns "github.com/netbirdio/netbird/dns" proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index c4bc8aef6b9..0b9a2d505d2 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/netbirdio/netbird/client/ssh/auth" + auth "github.com/netbirdio/netbird/shared/sessionauth" nbdns "github.com/netbirdio/netbird/dns" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index cd5500bad73..672dce3c691 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -6,7 +6,7 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/ssh/auth" + auth "github.com/netbirdio/netbird/shared/sessionauth" nbpeer "github.com/netbirdio/netbird/management/server/peer" ) @@ -28,6 +28,9 @@ type VNCSessionPubKey struct { PubKey string // UserID is the unhashed user identity the pubkey authenticates as. UserID string + // DisplayName is a human-readable label for UserID, used by the host + // peer's approval prompt. Empty when not provided. + DisplayName string } // ruleAuthCallbacks lets Account and NetworkMapComponents share the per-rule @@ -83,8 +86,9 @@ func (cb ruleAuthCallbacks) handleVNCRule(rule *PolicyRule, peerInSources, peerI cb.collectVNCUsers(rule, state.vncAuthorizedUsers) if peerInDestinations && rule.SessionPubKey != "" && rule.AuthorizedUser != "" { state.vncSessionPubKeys = append(state.vncSessionPubKeys, VNCSessionPubKey{ - PubKey: rule.SessionPubKey, - UserID: rule.AuthorizedUser, + PubKey: rule.SessionPubKey, + UserID: rule.AuthorizedUser, + DisplayName: rule.SessionDisplayName, }) } } diff --git a/management/server/types/policyrule.go b/management/server/types/policyrule.go index 054a08266a7..fdcbd5e6ef5 100644 --- a/management/server/types/policyrule.go +++ b/management/server/types/policyrule.go @@ -94,6 +94,13 @@ type PolicyRule struct { // AuthorizedUser when the rule was created via temporary-access for a // VNC scope; empty otherwise. SessionPubKey string + + // SessionDisplayName is a human-readable label for the user the + // SessionPubKey was issued to (typically display name, falling back + // to email or user id). The daemon surfaces it on the host's + // per-connection approval prompt so the user being asked can + // recognise who is requesting access. + SessionDisplayName string } // Copy returns a copy of a policy rule @@ -116,6 +123,7 @@ func (pm *PolicyRule) Copy() *PolicyRule { AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)), AuthorizedUser: pm.AuthorizedUser, SessionPubKey: pm.SessionPubKey, + SessionDisplayName: pm.SessionDisplayName, } copy(rule.Destinations, pm.Destinations) copy(rule.Sources, pm.Sources) @@ -144,7 +152,8 @@ func (pm *PolicyRule) Equal(other *PolicyRule) bool { pm.SourceResource != other.SourceResource || pm.DestinationResource != other.DestinationResource || pm.AuthorizedUser != other.AuthorizedUser || - pm.SessionPubKey != other.SessionPubKey { + pm.SessionPubKey != other.SessionPubKey || + pm.SessionDisplayName != other.SessionDisplayName { return false } diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index a4a8d17d7ea..ef029f79f0e 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -2761,6 +2761,12 @@ type SessionPubKey struct { // UserIDHash is the BLAKE2b-128 hash of the user ID this session // belongs to, matching the entries in VNCAuth.AuthorizedUsers. UserIdHash []byte `protobuf:"bytes,2,opt,name=user_id_hash,json=userIdHash,proto3" json:"user_id_hash,omitempty"` + // DisplayName is a human-readable label for the user this session was + // issued to (typically the IDP display name, falling back to email). + // Used by the host peer's UI in the per-connection approval prompt so + // the user being asked can recognise the requester. May be empty when + // the management server has no user record for the session. + DisplayName string `protobuf:"bytes,3,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` } func (x *SessionPubKey) Reset() { @@ -2809,6 +2815,13 @@ func (x *SessionPubKey) GetUserIdHash() []byte { return nil } +func (x *SessionPubKey) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + // RemotePeerConfig represents a configuration of a remote peer. // The properties are used to configure WireGuard Peers sections type RemotePeerConfig struct { @@ -5091,346 +5104,348 @@ var file_management_proto_rawDesc = []byte{ 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x4a, 0x0a, 0x0d, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x6d, 0x0a, 0x0d, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x20, 0x0a, 0x0c, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, - 0x61, 0x73, 0x68, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, - 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, - 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, - 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, - 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, - 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, - 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, - 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, - 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, - 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, - 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, - 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, - 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, - 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, - 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, - 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, - 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, - 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, - 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, - 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, - 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, - 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, - 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, - 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, - 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, + 0x61, 0x73, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, + 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, + 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, + 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, + 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, + 0x65, 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, + 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, + 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, + 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, + 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, + 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, + 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, + 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, + 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, + 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, + 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, + 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, + 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, + 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, + 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, - 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, - 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, - 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, - 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, - 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, - 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, - 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, - 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, - 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, - 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, - 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, - 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, - 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, - 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, - 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, - 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, - 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, - 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, - 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, - 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, - 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, - 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, - 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, - 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, - 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, - 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, - 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, - 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, - 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, - 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, - 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, - 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, - 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, - 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, - 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, - 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, - 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, - 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, - 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, - 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, - 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, - 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, - 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, - 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, - 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, - 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, - 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, - 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, - 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, - 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, - 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, - 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, - 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, - 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, - 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, - 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, - 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, - 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, - 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, - 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, - 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, - 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, - 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, - 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, + 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, + 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, + 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, + 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, + 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, + 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, + 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, + 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, + 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, + 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, + 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, + 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, + 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, + 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, + 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, + 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, + 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, + 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, + 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, + 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, + 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, + 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, + 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, + 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, + 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, + 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, + 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, + 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, + 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, + 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, + 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, + 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, + 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, + 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, + 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, + 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, - 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, - 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, - 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, - 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, - 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, - 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, + 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, + 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, + 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, + 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, + 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, + 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, + 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, + 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, + 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, + 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, + 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, + 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, + 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, + 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, + 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, + 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, + 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, + 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, + 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, + 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, + 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, + 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, + 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, + 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, + 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, + 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, - 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, - 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, - 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, - 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, - 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, - 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, - 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, - 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, - 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, - 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, - 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, - 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, - 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, - 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, - 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, - 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, - 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, - 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, - 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, - 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, - 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, - 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, - 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, - 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, - 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, - 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x2a, 0x4c, 0x0a, - 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, - 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, - 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, - 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, - 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52, - 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, - 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, - 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, - 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, - 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, - 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, - 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, - 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, - 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, - 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, - 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, + 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, + 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, + 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, + 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, + 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, + 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, + 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, + 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, + 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, + 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, + 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, + 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, + 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, + 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, + 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, + 0x65, 0x64, 0x65, 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, + 0x10, 0x02, 0x2a, 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, + 0x6c, 0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, + 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, + 0x20, 0x0a, 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, + 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, + 0x2a, 0x4c, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, + 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, + 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, + 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, + 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, + 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, + 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, + 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, + 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, + 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, + 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, + 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, + 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, + 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, + 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, + 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, + 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, + 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, - 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, - 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, - 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, - 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, + 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, + 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, + 0x30, 0x01, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, + 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, - 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, - 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, - 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, - 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, - 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, + 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, - 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, - 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, - 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, + 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 756cdcbbbeb..421712ccdda 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -453,6 +453,13 @@ message SessionPubKey { // UserIDHash is the BLAKE2b-128 hash of the user ID this session // belongs to, matching the entries in VNCAuth.AuthorizedUsers. bytes user_id_hash = 2; + + // DisplayName is a human-readable label for the user this session was + // issued to (typically the IDP display name, falling back to email). + // Used by the host peer's UI in the per-connection approval prompt so + // the user being asked can recognise the requester. May be empty when + // the management server has no user record for the session. + string display_name = 3; } // RemotePeerConfig represents a configuration of a remote peer. diff --git a/client/ssh/auth/auth.go b/shared/sessionauth/auth.go similarity index 85% rename from client/ssh/auth/auth.go rename to shared/sessionauth/auth.go index 782c816af6b..e438d883156 100644 --- a/client/ssh/auth/auth.go +++ b/shared/sessionauth/auth.go @@ -1,4 +1,4 @@ -package auth +package sessionauth import ( "errors" @@ -43,6 +43,11 @@ type Authorizer struct { // Populated from management's temporary-access flow; used by VNC to // authenticate via the Noise_IK handshake. sessionPubKeys map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash + // sessionDisplayNames mirrors sessionPubKeys with the optional + // human-readable display name management associated with each + // session key. Used by the per-connection UI approval prompt; not + // consulted by any authorization decision. + sessionDisplayNames map[[sessionPubKeyLen]byte]string // mu protects the list of users mu sync.RWMutex @@ -66,10 +71,13 @@ type Config struct { } // SessionPubKey is a single ephemeral-key entry: the 32-byte X25519 -// static public key plus the hashed user identity it authenticates as. +// static public key plus the hashed user identity it authenticates as, +// optionally plus a human-readable display name for the UI approval +// prompt to identify the requester. type SessionPubKey struct { - PubKey []byte - UserIDHash sshuserhash.UserIDHash + PubKey []byte + UserIDHash sshuserhash.UserIDHash + DisplayName string } // NewAuthorizer creates a new SSH authorizer with empty configuration @@ -77,7 +85,8 @@ func NewAuthorizer() *Authorizer { a := &Authorizer{ userIDClaim: DefaultUserIDClaim, machineUsers: make(map[string][]uint32), - sessionPubKeys: make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash), + sessionPubKeys: make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash), + sessionDisplayNames: make(map[[sessionPubKeyLen]byte]string), } return a @@ -94,6 +103,7 @@ func (a *Authorizer) Update(config *Config) { a.authorizedUsers = []sshuserhash.UserIDHash{} a.machineUsers = make(map[string][]uint32) a.sessionPubKeys = make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash) + a.sessionDisplayNames = make(map[[sessionPubKeyLen]byte]string) log.Info("SSH authorization cleared") return } @@ -117,6 +127,7 @@ func (a *Authorizer) Update(config *Config) { a.machineUsers = machineUsers sessionPubKeys := make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash, len(config.SessionPubKeys)) + sessionDisplayNames := make(map[[sessionPubKeyLen]byte]string, len(config.SessionPubKeys)) conflicted := make(map[[sessionPubKeyLen]byte]struct{}) for _, e := range config.SessionPubKeys { if len(e.PubKey) != sessionPubKeyLen { @@ -130,12 +141,17 @@ func (a *Authorizer) Update(config *Config) { if existing, ok := sessionPubKeys[key]; ok && existing != e.UserIDHash { log.Warnf("SSH auth: session pubkey bound to conflicting user hashes; dropping binding") delete(sessionPubKeys, key) + delete(sessionDisplayNames, key) conflicted[key] = struct{}{} continue } sessionPubKeys[key] = e.UserIDHash + if e.DisplayName != "" { + sessionDisplayNames[key] = e.DisplayName + } } a.sessionPubKeys = sessionPubKeys + a.sessionDisplayNames = sessionDisplayNames log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings, %d session pubkeys", len(config.AuthorizedUsers), len(machineUsers), len(sessionPubKeys)) @@ -217,6 +233,22 @@ func (a *Authorizer) LookupSessionKey(pubKey []byte) (sshuserhash.UserIDHash, er return hash, nil } +// LookupSessionDisplayName returns the human-readable display name +// management associated with a session pubkey, or empty string when none +// is recorded. Never returns an error: a missing/unknown key reports as +// "" and the caller falls back to other identifiers. +func (a *Authorizer) LookupSessionDisplayName(pubKey []byte) string { + if len(pubKey) != sessionPubKeyLen { + return "" + } + var key [sessionPubKeyLen]byte + copy(key[:], pubKey) + a.mu.RLock() + name := a.sessionDisplayNames[key] + a.mu.RUnlock() + return name +} + // AuthorizeOSUserBySessionKey resolves the OS-user mapping for a session // key. Mirrors Authorize but skips the JWT-hash step since the key has // already been verified and the user identity hash is in hand. diff --git a/client/ssh/auth/auth_test.go b/shared/sessionauth/auth_test.go similarity index 99% rename from client/ssh/auth/auth_test.go rename to shared/sessionauth/auth_test.go index 87047bb2b81..6c5395f4e99 100644 --- a/client/ssh/auth/auth_test.go +++ b/shared/sessionauth/auth_test.go @@ -1,4 +1,4 @@ -package auth +package sessionauth import ( "errors" From 8d329da59136e8befcf321b7293d07c4381c39c3 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 23 May 2026 17:55:18 +0200 Subject: [PATCH 078/151] Evict orphaned packet captures and annotate VNC streams --- client/internal/engine_vnc.go | 21 ++-- client/server/capture.go | 37 +++++-- client/vnc/ports.go | 31 ++++++ client/vnc/server/agent_darwin.go | 4 +- util/capture/text.go | 169 +++++++++++++++++++++++++++++- 5 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 client/vnc/ports.go diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 82b92146e71..ab1f4f216dc 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -15,16 +15,13 @@ import ( "github.com/netbirdio/netbird/client/internal/metrics" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/peer" - sshauth "github.com/netbirdio/netbird/shared/sessionauth" + "github.com/netbirdio/netbird/client/vnc" vncserver "github.com/netbirdio/netbird/client/vnc/server" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" mgmProto "github.com/netbirdio/netbird/shared/management/proto" sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) -const ( - vncExternalPort uint16 = 5900 - vncInternalPort uint16 = 25900 -) type vncServer interface { Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error @@ -42,10 +39,10 @@ func (e *Engine) setupVNCPortRedirection() error { return errors.New("invalid local NetBird address") } - if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil { + if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil { return fmt.Errorf("add VNC port redirection: %w", err) } - log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vncExternalPort, localAddr, vncInternalPort) + log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vnc.ExternalPort, localAddr, vnc.InternalPort) return nil } @@ -60,7 +57,7 @@ func (e *Engine) cleanupVNCPortRedirection() error { return errors.New("invalid local NetBird address") } - if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil { + if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil { return fmt.Errorf("remove VNC port redirection: %w", err) } @@ -132,7 +129,7 @@ func (e *Engine) startVNCServer() error { Approver: &vncApprover{broker: e.approvalBroker, statusRecorder: e.statusRecorder}, }) - listenAddr := netip.AddrPortFrom(netbirdIP, vncInternalPort) + listenAddr := netip.AddrPortFrom(netbirdIP, vnc.InternalPort) network := e.wgInterface.Address().Network if err := srv.Start(e.ctx, listenAddr, network); err != nil { return fmt.Errorf("start VNC server: %w", err) @@ -144,8 +141,8 @@ func (e *Engine) startVNCServer() error { if registrar, ok := e.firewall.(interface { RegisterNetstackService(protocol nftypes.Protocol, port uint16) }); ok { - registrar.RegisterNetstackService(nftypes.TCP, vncInternalPort) - log.Debugf("registered VNC service with netstack for TCP:%d", vncInternalPort) + registrar.RegisterNetstackService(nftypes.TCP, vnc.InternalPort) + log.Debugf("registered VNC service with netstack for TCP:%d", vnc.InternalPort) } } @@ -231,7 +228,7 @@ func (e *Engine) stopVNCServer() error { if registrar, ok := e.firewall.(interface { UnregisterNetstackService(protocol nftypes.Protocol, port uint16) }); ok { - registrar.UnregisterNetstackService(nftypes.TCP, vncInternalPort) + registrar.UnregisterNetstackService(nftypes.TCP, vnc.InternalPort) } } diff --git a/client/server/capture.go b/client/server/capture.go index 308c00338c9..8975fdc7554 100644 --- a/client/server/capture.go +++ b/client/server/capture.go @@ -190,10 +190,7 @@ func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCap s.stopBundleCaptureLocked() s.cleanupBundleCapture() - - if s.activeCapture != nil { - return nil, status.Error(codes.FailedPrecondition, "another capture is already running") - } + s.evictActiveCaptureLocked() engine, err := s.getCaptureEngineLocked() if err != nil { @@ -304,15 +301,15 @@ func (s *Server) cleanupBundleCapture() { s.bundleCapture = nil } -// claimCapture reserves the engine's capture slot for sess. Returns -// FailedPrecondition if another capture is already active. +// claimCapture reserves the engine's capture slot for sess. If another +// capture is already running it is evicted: a previous streaming session +// whose gRPC client died and never freed the slot stays stuck otherwise, +// and a bundle capture is just informational state. func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) { s.mutex.Lock() defer s.mutex.Unlock() - if s.activeCapture != nil { - return nil, status.Error(codes.FailedPrecondition, "another capture is already running") - } + s.evictActiveCaptureLocked() engine, err := s.getCaptureEngineLocked() if err != nil { return nil, err @@ -321,6 +318,28 @@ func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) { return engine, nil } +// evictActiveCaptureLocked tears down whatever capture currently owns +// the engine slot so a fresh claim can succeed. Caller must hold mutex. +func (s *Server) evictActiveCaptureLocked() { + if s.activeCapture == nil { + return + } + if s.bundleCapture != nil && s.bundleCapture.sess == s.activeCapture { + log.Infof("evicting running bundle capture to start a new capture") + s.stopBundleCaptureLocked() + return + } + log.Infof("evicting previous streaming capture to start a new one") + prev := s.activeCapture + if engine, err := s.getCaptureEngineLocked(); err == nil { + if err := engine.SetCapture(nil); err != nil { + log.Debugf("clear previous capture: %v", err) + } + } + s.activeCapture = nil + prev.Stop() +} + // releaseCapture clears the active-capture owner if it still matches sess. func (s *Server) releaseCapture(sess *capture.Session) { s.mutex.Lock() diff --git a/client/vnc/ports.go b/client/vnc/ports.go new file mode 100644 index 00000000000..5e80810c1ec --- /dev/null +++ b/client/vnc/ports.go @@ -0,0 +1,31 @@ +// Package vnc holds shared constants for the NetBird embedded VNC stack +// so non-server consumers (CLI capture, debug tooling) can refer to the +// well-known ports without depending on internal engine packages. +package vnc + +// External and internal listen ports for the embedded VNC server. +// ExternalPort is what dashboard / browser clients see; the daemon +// DNATs it to InternalPort, where the in-process VNC server actually +// listens. Both flow over the WireGuard interface. AgentLegacyPort is +// the TCP port the per-session agent used before it switched to Unix +// sockets; kept here so packet captures from older builds still get +// tagged, and so any future on-wire agent variant has a reserved port. +const ( + ExternalPort uint16 = 5900 + InternalPort uint16 = 25900 + AgentLegacyPort uint16 = 15900 +) + +// WellKnownPorts is the unordered set of ports a packet capture should +// treat as carrying NetBird VNC traffic. +var WellKnownPorts = [...]uint16{ExternalPort, InternalPort, AgentLegacyPort} + +// IsWellKnownPort reports whether port matches any of WellKnownPorts. +func IsWellKnownPort(port uint16) bool { + for _, p := range WellKnownPorts { + if port == p { + return true + } + } + return false +} diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index 3da2c5aeee8..edaf5bdf16b 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -222,8 +222,8 @@ func waitForAgent(ctx context.Context, socketPath string, wait time.Duration) er } // vncAgentRunning reports whether any vnc-agent process exists on the -// system. The daemon owns the only port-15900 listener model, so any -// match is "the" agent. +// system. There is at most one agent per machine, so any match is "the" +// agent. func vncAgentRunning() bool { pids, err := vncAgentPIDs() if err != nil { diff --git a/util/capture/text.go b/util/capture/text.go index a6a6dd28b12..d830d3ab37b 100644 --- a/util/capture/text.go +++ b/util/capture/text.go @@ -90,7 +90,7 @@ func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, // Protocol annotation var annotation string if plen > 0 { - annotation = annotatePayload(tcp.Payload) + annotation = annotatePayload(tcp.Payload, info.srcPort, info.dstPort) } if !tw.verbose { @@ -363,8 +363,11 @@ func formatTCPOptions(opts []layers.TCPOption) string { // --- Protocol annotation --- -// annotatePayload returns a protocol annotation string for known application protocols. -func annotatePayload(payload []byte) string { +// annotatePayload returns a protocol annotation string for known +// application protocols. srcPort/dstPort enable port-tagged +// annotations (e.g. NetBird VNC traffic) that can't be identified from +// the payload alone. +func annotatePayload(payload []byte, srcPort, dstPort uint16) string { if len(payload) < 4 { return "" } @@ -397,9 +400,169 @@ func annotatePayload(payload []byte) string { } } + // NetBird VNC: tag by port and try a few payload heuristics. + if isVNCPort(srcPort, dstPort) { + return ": " + annotateVNC(payload, srcPort, dstPort) + } + + return "" +} + +// isVNCPort mirrors client/vnc/ports.go. 5900 is the external port the +// dashboard talks to, 25900 is the internal DNAT target, 15900 is the +// legacy agent TCP port (agents now use Unix sockets, but historical +// captures are still useful). +func isVNCPort(src, dst uint16) bool { + return src == 5900 || dst == 5900 || + src == 25900 || dst == 25900 || + src == 15900 || dst == 15900 +} + +// annotateVNC inspects a payload assumed to be on a NetBird VNC port and +// returns a short tag. The annotation is stateless and best-effort: we +// don't track per-flow phase, so msg-type decoding only fires when the +// length plausibly matches a known fixed-size RFB message. +func annotateVNC(payload []byte, srcPort, dstPort uint16) string { + s := string(payload) + + // Banner / control-plane recognitions match in either direction. + switch { + case strings.HasPrefix(s, "RFB 003."): + end := strings.IndexByte(s, '\n') + if end > 0 && end < 32 { + return "VNC " + strings.TrimSpace(s[:end]) + } + return "VNC RFB" + case strings.Contains(s, "\x00NB-VIEW-ONLY\x00"): + return "VNC view-only announce" + case isVNCRejectPrefix(s): + if i := strings.IndexByte(s, ':'); i > 0 && i < 32 { + return "VNC reject " + s[:i] + } + return "VNC reject" + } + + // Direction by port. Well-known VNC port is always on the server + // side; the other end is an ephemeral client port. + dstIsServer := isWellKnownVNCPort(dstPort) + srcIsServer := isWellKnownVNCPort(srcPort) + switch { + case dstIsServer && !srcIsServer: + return "VNC " + annotateVNCClientToServer(payload) + case srcIsServer && !dstIsServer: + return "VNC " + annotateVNCServerToClient(payload) + } + return "VNC" +} + +func isWellKnownVNCPort(p uint16) bool { + return p == 5900 || p == 25900 || p == 15900 +} + +// annotateVNCClientToServer guesses what a client-bound payload contains. +// First-packet path: looks for the NetBird connection header. RFB +// message-type recognitions fire only when length matches the fixed +// size for that type, to avoid mis-tagging Noise handshake bytes. +func annotateVNCClientToServer(p []byte) string { + if len(p) >= 10 && (p[0] == 0 || p[0] == 1) { + userLen := int(p[1]) + // width and height are uint16 fields the dashboard often leaves + // zero (default). A header without an OS user has total length + // 10; with one, 10+userLen. + if 10+userLen <= len(p) { + mode := "attach" + if p[0] == 1 { + mode = "session" + } + tag := fmt.Sprintf("connect mode=%s", mode) + if userLen > 0 { + tag += fmt.Sprintf(" user(%d)", userLen) + } + return tag + } + } + switch { + case len(p) == 20 && p[0] == 0: + return "SetPixelFormat" + case p[0] == 2: + return "SetEncodings" + case len(p) == 10 && p[0] == 3: + return "FramebufferUpdateRequest" + case len(p) == 8 && p[0] == 4: + return "KeyEvent" + case (len(p) == 6 || len(p) == 7) && p[0] == 5: + return "PointerEvent" + case p[0] == 6: + return "ClientCutText" + case p[0] == 0xFC: + return "QEMUClientMsg" + } + return "" +} + +// annotateVNCServerToClient guesses what a server-bound payload contains. +// The security-failure path (numTypes=0 + 4-byte reasonLen + reason) is +// recognised before FramebufferUpdate because both start with 0x00 and +// the failure carries a self-describing length we can verify. +func annotateVNCServerToClient(p []byte) string { + if reason, ok := matchRFBSecurityFailure(p); ok { + if code, _, found := strings.Cut(reason, ": "); found && isVNCRejectPrefix(reason) { + return "reject " + code + } + return "reject" + } + switch { + case len(p) >= 4 && p[0] == 0: + return "FramebufferUpdate" + case p[0] == 1: + return "SetColorMapEntries" + case len(p) == 1 && p[0] == 2: + return "Bell" + case p[0] == 3: + return "ServerCutText" + } return "" } +// matchRFBSecurityFailure recognises the RFB 3.8 security-result body the +// server sends when authentication or session setup fails. Format: +// byte 0 : 0x00 (security types count = 0 = failure) +// bytes 1-4: uint32 reason length +// bytes 5+: reason text +// Returns the reason text and ok=true when the length self-checks. +func matchRFBSecurityFailure(p []byte) (string, bool) { + if len(p) < 5 || p[0] != 0 { + return "", false + } + reasonLen := int(p[1])<<24 | int(p[2])<<16 | int(p[3])<<8 | int(p[4]) + if reasonLen <= 0 || reasonLen > 4096 || 5+reasonLen != len(p) { + return "", false + } + return string(p[5 : 5+reasonLen]), true +} + +// vncRejectCodes mirrors the RejectCode* constants in +// client/vnc/server/server.go. New codes should be added here too. +var vncRejectCodes = [...]string{ + "AUTH_FORBIDDEN", + "SESSION_ERROR", + "CAPTURER_ERROR", + "UNSUPPORTED", + "BAD_REQUEST", + "NO_CONSOLE_USER", + "APPROVAL_DENIED", + "NO_APPROVER", +} + +func isVNCRejectPrefix(s string) bool { + for _, c := range vncRejectCodes { + if strings.HasPrefix(s, c+":") { + return true + } + } + return false +} + // annotateTLS returns a description for TLS handshake and alert records. func annotateTLS(data []byte) string { if len(data) < 6 { From 1f912be673e7fb24de1fe55b32dfdadc08a41505 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 23 May 2026 19:06:02 +0200 Subject: [PATCH 079/151] Address codespell and Sonar findings on embedded-vnc --- client/cmd/up.go | 92 +++++---- client/ui/approval.go | 2 +- client/vnc/server/handshake.go | 250 ++++++++++++++++++++++++ client/vnc/server/server.go | 284 +++------------------------- client/vnc/server/session_encode.go | 48 +++-- 5 files changed, 355 insertions(+), 321 deletions(-) create mode 100644 client/vnc/server/handshake.go diff --git a/client/cmd/up.go b/client/cmd/up.go index 8ffc7c2f761..e461e948846 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -480,29 +480,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.DisableVNCApproval = &disableVNCApproval } - if cmd.Flag(enableSSHRootFlag).Changed { - ic.EnableSSHRoot = &enableSSHRoot - } - - if cmd.Flag(enableSSHSFTPFlag).Changed { - ic.EnableSSHSFTP = &enableSSHSFTP - } - - if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { - ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward - } - - if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { - ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward - } - - if cmd.Flag(disableSSHAuthFlag).Changed { - ic.DisableSSHAuth = &disableSSHAuth - } - - if cmd.Flag(sshJWTCacheTTLFlag).Changed { - ic.SSHJWTCacheTTL = &sshJWTCacheTTL - } + applySSHFlagsToConfig(cmd, &ic) if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { @@ -578,6 +556,49 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil return &ic, nil } +func applySSHFlagsToConfig(cmd *cobra.Command, ic *profilemanager.ConfigInput) { + if cmd.Flag(enableSSHRootFlag).Changed { + ic.EnableSSHRoot = &enableSSHRoot + } + if cmd.Flag(enableSSHSFTPFlag).Changed { + ic.EnableSSHSFTP = &enableSSHSFTP + } + if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { + ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward + } + if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { + ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward + } + if cmd.Flag(disableSSHAuthFlag).Changed { + ic.DisableSSHAuth = &disableSSHAuth + } + if cmd.Flag(sshJWTCacheTTLFlag).Changed { + ic.SSHJWTCacheTTL = &sshJWTCacheTTL + } +} + +func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) { + if cmd.Flag(enableSSHRootFlag).Changed { + req.EnableSSHRoot = &enableSSHRoot + } + if cmd.Flag(enableSSHSFTPFlag).Changed { + req.EnableSSHSFTP = &enableSSHSFTP + } + if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { + req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward + } + if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { + req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward + } + if cmd.Flag(disableSSHAuthFlag).Changed { + req.DisableSSHAuth = &disableSSHAuth + } + if cmd.Flag(sshJWTCacheTTLFlag).Changed { + ttl := int32(sshJWTCacheTTL) + req.SshJWTCacheTTL = &ttl + } +} + func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) { loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, @@ -614,30 +635,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.DisableVNCApproval = &disableVNCApproval } - if cmd.Flag(enableSSHRootFlag).Changed { - loginRequest.EnableSSHRoot = &enableSSHRoot - } - - if cmd.Flag(enableSSHSFTPFlag).Changed { - loginRequest.EnableSSHSFTP = &enableSSHSFTP - } - - if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { - loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward - } - - if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { - loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward - } - - if cmd.Flag(disableSSHAuthFlag).Changed { - loginRequest.DisableSSHAuth = &disableSSHAuth - } - - if cmd.Flag(sshJWTCacheTTLFlag).Changed { - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32 - } + applySSHFlagsToLogin(cmd, &loginRequest) if cmd.Flag(disableAutoConnectFlag).Changed { loginRequest.DisableAutoConnect = &autoConnectDisabled diff --git a/client/ui/approval.go b/client/ui/approval.go index 9e5beaf5cca..70c8963d505 100644 --- a/client/ui/approval.go +++ b/client/ui/approval.go @@ -171,7 +171,7 @@ func (r approvalRequest) displayPeer() string { } // deadline returns the wall-clock auto-deny moment. Falls back to a short -// local window when the daemon's expires_at is missing/unparseable, so a +// local window when the daemon's expires_at is missing/unparsable, so a // stale value never leaves the dialog open indefinitely. func (r approvalRequest) deadline() time.Time { if t, err := time.Parse(time.RFC3339, r.expiresAt); err == nil { diff --git a/client/vnc/server/handshake.go b/client/vnc/server/handshake.go new file mode 100644 index 00000000000..50231cff8ab --- /dev/null +++ b/client/vnc/server/handshake.go @@ -0,0 +1,250 @@ +//go:build !js && !ios && !android + +package server + +import ( + "bufio" + "bytes" + "crypto/subtle" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "time" + + "github.com/flynn/noise" + log "github.com/sirupsen/logrus" +) + +var vncIdentityMagic = []byte("NBV3") + +// Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads). +// +// msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes +// msg2 = e(32) + payload_AEAD(0+16) = 48 bytes +const ( + noiseInitiatorMsgLen = 96 + noiseResponderMsgLen = 48 +) + +// vncNoiseSuite pins the cipher suite for the VNC handshake. Changing +// it requires bumping vncIdentityMagic so old clients fail closed. +var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) + +func (s *Server) authenticateSession(header *connectionHeader) (string, error) { + if !header.identityVerified { + return "", fmt.Errorf("identity proof missing") + } + if len(header.clientStatic) != 32 { + return "", fmt.Errorf("client static key missing") + } + + userIDHash, err := s.authorizer.LookupSessionKey(header.clientStatic) + if err != nil { + return "", fmt.Errorf("lookup session pubkey: %w", err) + } + + osUser := "*" + if header.mode == ModeSession { + osUser = header.username + } + if _, err := s.authorizer.AuthorizeOSUserBySessionKey(userIDHash, osUser); err != nil { + return "", fmt.Errorf("authorize OS user %q: %w", osUser, err) + } + return userIDHash.String(), nil +} + +// readConnectionHeader reads the NetBird VNC session header. Format: +// +// [mode: 1] [username_len: 2 BE] [username: N] +// [opt magic "NBV3": 4] [noise_msg1: 96] +// (server writes [noise_msg2: 48] here when the magic is present) +// [session_id: 4 BE] [width: 2 BE] [height: 2 BE] +// +// Standard VNC clients don't speak first, so they time out on the first +// read and fall through to attach mode (which auth still rejects when +// no Noise handshake completed). +func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) { + if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + return nil, fmt.Errorf("set deadline: %w", err) + } + defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck + + var hdr [3]byte + if _, err := io.ReadFull(conn, hdr[:]); err != nil { + return &connectionHeader{mode: ModeAttach}, nil + } + + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + return nil, fmt.Errorf("set deadline: %w", err) + } + + mode := hdr[0] + usernameLen := binary.BigEndian.Uint16(hdr[1:3]) + + var username string + if usernameLen > 0 { + if usernameLen > 256 { + return nil, fmt.Errorf("username too long: %d", usernameLen) + } + buf := make([]byte, usernameLen) + if _, err := io.ReadFull(conn, buf); err != nil { + return nil, fmt.Errorf("read username: %w", err) + } + username = string(buf) + } + + br := bufio.NewReader(conn) + clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br) + if err != nil { + return nil, err + } + + var sessionID uint32 + var sidBuf [4]byte + if _, err := io.ReadFull(br, sidBuf[:]); err == nil { + sessionID = binary.BigEndian.Uint32(sidBuf[:]) + } + + var width, height uint16 + var geomBuf [4]byte + if _, err := io.ReadFull(br, geomBuf[:]); err == nil { + width = binary.BigEndian.Uint16(geomBuf[0:2]) + height = binary.BigEndian.Uint16(geomBuf[2:4]) + } + + return &connectionHeader{ + mode: mode, + username: username, + clientStatic: clientStatic, + sessionID: sessionID, + width: width, + height: height, + identityVerified: identityVerified, + }, nil +} + +// maybeRunNoiseHandshake performs the responder side of a Noise_IK +// handshake when the client sends the v3 magic. Returns the client static +// public key learned from the handshake. Any handshake failure is fatal +// (fail closed). +func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte, bool, error) { + peek, _ := br.Peek(len(vncIdentityMagic)) + if !bytes.Equal(peek, vncIdentityMagic) { + return nil, false, nil + } + if _, err := br.Discard(len(vncIdentityMagic)); err != nil { + return nil, false, fmt.Errorf("discard identity magic: %w", err) + } + + msg1 := make([]byte, noiseInitiatorMsgLen) + if _, err := io.ReadFull(br, msg1); err != nil { + return nil, false, fmt.Errorf("read noise msg1: %w", err) + } + + // Agents on loopback authenticate via the agent token, not this + // handshake. Consume the replayed bytes and skip the response. + if s.disableAuth { + return nil, true, nil + } + + if len(s.identityKey) != 32 || len(s.identityPublic) != 32 { + return nil, false, errors.New("identity key not configured") + } + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: false, + StaticKeypair: noise.DHKey{Private: s.identityKey, Public: s.identityPublic}, + }) + if err != nil { + return nil, false, fmt.Errorf("noise responder init: %w", err) + } + if _, _, _, err := state.ReadMessage(nil, msg1); err != nil { + return nil, false, fmt.Errorf("noise read msg1: %w", err) + } + msg2, _, _, err := state.WriteMessage(nil, nil) + if err != nil { + return nil, false, fmt.Errorf("noise write msg2: %w", err) + } + if len(msg2) != noiseResponderMsgLen { + return nil, false, fmt.Errorf("noise responder produced %d bytes, expected %d", len(msg2), noiseResponderMsgLen) + } + if _, err := conn.Write(msg2); err != nil { + return nil, false, fmt.Errorf("write noise msg2: %w", err) + } + + clientStatic := state.PeerStatic() + if len(clientStatic) != 32 { + return nil, false, errors.New("noise peer static missing") + } + return clientStatic, true, nil +} + +// verifyAgentToken validates the agent token prefix when configured and +// reads the trailing view-only flag byte the daemon writes alongside it. +// Returns (ok, viewOnly). ok=false closes the connection. +func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) (bool, bool) { + if len(s.agentToken) == 0 { + return true, false + } + buf := make([]byte, len(s.agentToken)+1) + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + connLog.Debugf("set agent token deadline: %v", err) + conn.Close() + return false, false + } + if _, err := io.ReadFull(conn, buf); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + // Connect-then-close probes (port liveness checks) hit this + // path on every dial; logging them would just flood the + // daemon log without surfacing a real failure. + connLog.Tracef("agent auth: read preamble: %v", err) + } else { + connLog.Warnf("agent auth: read preamble: %v", err) + } + conn.Close() + return false, false + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + connLog.Debugf("clear agent token deadline: %v", err) + } + if subtle.ConstantTimeCompare(buf[:len(s.agentToken)], s.agentToken) != 1 { + connLog.Warn("agent auth: invalid token, rejecting") + conn.Close() + return false, false + } + return true, buf[len(s.agentToken)] != 0 +} + +// authorizeSession runs the Noise_IK handshake when auth is enabled. +// Returns the enriched log entry, user identity hash (empty when auth +// disabled), and ok=false if the connection was rejected. +func (s *Server) authorizeSession(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) { + if s.disableAuth { + return connLog, "", true + } + userID, err := s.authenticateSession(header) + if err != nil { + rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) + connLog.Warnf("auth rejected: %v", err) + return connLog, "", false + } + return connLog.WithFields(log.Fields{ + "session_user": userID, + "session_key": sessionKeyFingerprint(header.clientStatic), + }), userID, true +} + +// sessionKeyFingerprint returns a short hex fingerprint of a client +// static key for log correlation. Distinct VNC sessions of the same +// user end up with distinct fingerprints because each session mints a +// fresh keypair, so this lets an operator tell parallel sessions apart. +func sessionKeyFingerprint(clientStatic []byte) string { + if len(clientStatic) < 4 { + return "" + } + return hex.EncodeToString(clientStatic[:4]) +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index d350be70f0a..f7c74ffdd5f 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -3,10 +3,7 @@ package server import ( - "bufio" - "bytes" "context" - "crypto/subtle" "encoding/binary" "encoding/hex" "errors" @@ -18,7 +15,6 @@ import ( "sync" "time" - "github.com/flynn/noise" log "github.com/sirupsen/logrus" "golang.org/x/crypto/curve25519" "golang.zx2c4.com/wireguard/tun/netstack" @@ -572,27 +568,12 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.P s.listener = s.preListener listenDesc = s.preListener.Addr().String() default: - if !network.IsValid() { - return fmt.Errorf("invalid overlay network prefix") - } - s.localAddr = addr.Addr() - s.network = network - if s.netstackNet != nil { - ln, err := s.netstackNet.ListenTCPAddrPort(addr) - if err != nil { - return fmt.Errorf("listen on netstack %s: %w", addr, err) - } - s.listener = ln - listenDesc = fmt.Sprintf("netstack %s", addr) - } else { - tcpAddr := net.TCPAddrFromAddrPort(addr) - ln, err := net.ListenTCP("tcp", tcpAddr) - if err != nil { - return fmt.Errorf("listen on %s: %w", addr, err) - } - s.listener = ln - listenDesc = addr.String() + ln, desc, err := s.openOverlayListener(addr, network) + if err != nil { + return err } + s.listener = ln + listenDesc = desc } if s.serviceMode { @@ -609,6 +590,27 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.P return nil } +func (s *Server) openOverlayListener(addr netip.AddrPort, network netip.Prefix) (net.Listener, string, error) { + if !network.IsValid() { + return nil, "", fmt.Errorf("invalid overlay network prefix") + } + s.localAddr = addr.Addr() + s.network = network + if s.netstackNet != nil { + ln, err := s.netstackNet.ListenTCPAddrPort(addr) + if err != nil { + return nil, "", fmt.Errorf("listen on netstack %s: %w", addr, err) + } + return ln, fmt.Sprintf("netstack %s", addr), nil + } + tcpAddr := net.TCPAddrFromAddrPort(addr) + ln, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + return nil, "", fmt.Errorf("listen on %s: %w", addr, err) + } + return ln, addr.String(), nil +} + // Stop shuts down the server and closes all connections. func (s *Server) Stop() error { s.mu.Lock() @@ -869,240 +871,6 @@ func rejectConnection(conn net.Conn, reason string) { _, _ = conn.Write(buf) } -// authenticateSession resolves the Noise-verified client static public -// key to a hashed user identity via the authorizer, and checks OS-user -// mapping for session mode. Returns the hashed user identity on success. -func (s *Server) authenticateSession(header *connectionHeader) (string, error) { - if !header.identityVerified { - return "", fmt.Errorf("identity proof missing") - } - if len(header.clientStatic) != 32 { - return "", fmt.Errorf("client static key missing") - } - - userIDHash, err := s.authorizer.LookupSessionKey(header.clientStatic) - if err != nil { - return "", fmt.Errorf("lookup session pubkey: %w", err) - } - - osUser := "*" - if header.mode == ModeSession { - osUser = header.username - } - if _, err := s.authorizer.AuthorizeOSUserBySessionKey(userIDHash, osUser); err != nil { - return "", fmt.Errorf("authorize OS user %q: %w", osUser, err) - } - return userIDHash.String(), nil -} - -var vncIdentityMagic = []byte("NBV3") - -// Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads). -// -// msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes -// msg2 = e(32) + payload_AEAD(0+16) = 48 bytes -const ( - noiseInitiatorMsgLen = 96 - noiseResponderMsgLen = 48 -) - -// vncNoiseSuite pins the cipher suite for the VNC handshake. Changing -// it requires bumping vncIdentityMagic so old clients fail closed. -var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) - -// readConnectionHeader reads the NetBird VNC session header. Format: -// -// [mode: 1] [username_len: 2 BE] [username: N] -// [opt magic "NBV3": 4] [noise_msg1: 96] -// (server writes [noise_msg2: 48] here when the magic is present) -// [session_id: 4 BE] [width: 2 BE] [height: 2 BE] -// -// Standard VNC clients don't speak first, so they time out on the first -// read and fall through to attach mode (which auth still rejects when -// no Noise handshake completed). -func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) { - if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - return nil, fmt.Errorf("set deadline: %w", err) - } - defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck - - var hdr [3]byte - if _, err := io.ReadFull(conn, hdr[:]); err != nil { - return &connectionHeader{mode: ModeAttach}, nil - } - - if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { - return nil, fmt.Errorf("set deadline: %w", err) - } - - mode := hdr[0] - usernameLen := binary.BigEndian.Uint16(hdr[1:3]) - - var username string - if usernameLen > 0 { - if usernameLen > 256 { - return nil, fmt.Errorf("username too long: %d", usernameLen) - } - buf := make([]byte, usernameLen) - if _, err := io.ReadFull(conn, buf); err != nil { - return nil, fmt.Errorf("read username: %w", err) - } - username = string(buf) - } - - br := bufio.NewReader(conn) - clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br) - if err != nil { - return nil, err - } - - var sessionID uint32 - var sidBuf [4]byte - if _, err := io.ReadFull(br, sidBuf[:]); err == nil { - sessionID = binary.BigEndian.Uint32(sidBuf[:]) - } - - var width, height uint16 - var geomBuf [4]byte - if _, err := io.ReadFull(br, geomBuf[:]); err == nil { - width = binary.BigEndian.Uint16(geomBuf[0:2]) - height = binary.BigEndian.Uint16(geomBuf[2:4]) - } - - return &connectionHeader{ - mode: mode, - username: username, - clientStatic: clientStatic, - sessionID: sessionID, - width: width, - height: height, - identityVerified: identityVerified, - }, nil -} - -// maybeRunNoiseHandshake performs the responder side of a Noise_IK -// handshake when the client sends the v3 magic. Returns the client static -// public key learned from the handshake. Any handshake failure is fatal -// (fail closed). -func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte, bool, error) { - peek, _ := br.Peek(len(vncIdentityMagic)) - if !bytes.Equal(peek, vncIdentityMagic) { - return nil, false, nil - } - if _, err := br.Discard(len(vncIdentityMagic)); err != nil { - return nil, false, fmt.Errorf("discard identity magic: %w", err) - } - - msg1 := make([]byte, noiseInitiatorMsgLen) - if _, err := io.ReadFull(br, msg1); err != nil { - return nil, false, fmt.Errorf("read noise msg1: %w", err) - } - - // Agents on loopback authenticate via the agent token, not this - // handshake. Consume the replayed bytes and skip the response. - if s.disableAuth { - return nil, true, nil - } - - if len(s.identityKey) != 32 || len(s.identityPublic) != 32 { - return nil, false, errors.New("identity key not configured") - } - state, err := noise.NewHandshakeState(noise.Config{ - CipherSuite: vncNoiseSuite, - Pattern: noise.HandshakeIK, - Initiator: false, - StaticKeypair: noise.DHKey{Private: s.identityKey, Public: s.identityPublic}, - }) - if err != nil { - return nil, false, fmt.Errorf("noise responder init: %w", err) - } - if _, _, _, err := state.ReadMessage(nil, msg1); err != nil { - return nil, false, fmt.Errorf("noise read msg1: %w", err) - } - msg2, _, _, err := state.WriteMessage(nil, nil) - if err != nil { - return nil, false, fmt.Errorf("noise write msg2: %w", err) - } - if len(msg2) != noiseResponderMsgLen { - return nil, false, fmt.Errorf("noise responder produced %d bytes, expected %d", len(msg2), noiseResponderMsgLen) - } - if _, err := conn.Write(msg2); err != nil { - return nil, false, fmt.Errorf("write noise msg2: %w", err) - } - - clientStatic := state.PeerStatic() - if len(clientStatic) != 32 { - return nil, false, errors.New("noise peer static missing") - } - return clientStatic, true, nil -} - -// verifyAgentToken validates the agent token prefix when configured and -// reads the trailing view-only flag byte the daemon writes alongside it. -// Returns (ok, viewOnly). ok=false closes the connection. -func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) (bool, bool) { - if len(s.agentToken) == 0 { - return true, false - } - buf := make([]byte, len(s.agentToken)+1) - if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { - connLog.Debugf("set agent token deadline: %v", err) - conn.Close() - return false, false - } - if _, err := io.ReadFull(conn, buf); err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { - // Connect-then-close probes (port liveness checks) hit this - // path on every dial; logging them would just flood the - // daemon log without surfacing a real failure. - connLog.Tracef("agent auth: read preamble: %v", err) - } else { - connLog.Warnf("agent auth: read preamble: %v", err) - } - conn.Close() - return false, false - } - if err := conn.SetReadDeadline(time.Time{}); err != nil { - connLog.Debugf("clear agent token deadline: %v", err) - } - if subtle.ConstantTimeCompare(buf[:len(s.agentToken)], s.agentToken) != 1 { - connLog.Warn("agent auth: invalid token, rejecting") - conn.Close() - return false, false - } - return true, buf[len(s.agentToken)] != 0 -} - -// authorizeSession runs the Noise_IK handshake when auth is enabled. -// Returns the enriched log entry, user identity hash (empty when auth -// disabled), and ok=false if the connection was rejected. -func (s *Server) authorizeSession(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) { - if s.disableAuth { - return connLog, "", true - } - userID, err := s.authenticateSession(header) - if err != nil { - rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) - connLog.Warnf("auth rejected: %v", err) - return connLog, "", false - } - return connLog.WithFields(log.Fields{ - "session_user": userID, - "session_key": sessionKeyFingerprint(header.clientStatic), - }), userID, true -} - -// sessionKeyFingerprint returns a short hex fingerprint of a client -// static key for log correlation. Distinct VNC sessions of the same -// user end up with distinct fingerprints because each session mints a -// fresh keypair, so this lets an operator tell parallel sessions apart. -func sessionKeyFingerprint(clientStatic []byte) string { - if len(clientStatic) < 4 { - return "" - } - return hex.EncodeToString(clientStatic[:4]) -} - // acquireSessionResources returns the capturer/injector to use for this // connection and a cleanup func to call when the session ends. ok is false // when the connection was rejected (and the caller must just return). diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 38fc058bade..bff37e6e7fb 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -449,19 +449,11 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { case useTight && tight != nil && pfIsTightCompatible(pf): rectBuf = encodeTightRect(img, pf, 0, 0, w, h, tight) case useZlib && zlib != nil: - // encodeZlibRect bakes in its own FBU header; reuse it for the - // single-rect path when there is no cursor to prepend. Fall back - // to Raw if the compressor errors out. - if zb, ok := encodeZlibRect(img, pf, 0, 0, w, h, zlib); ok { - if cursorRect == nil { - return s.writeFramed(zb) - } - rectBuf = zb[4:] - } else if cursorRect == nil { - return s.writeFramed(encodeRawRect(img, pf, 0, 0, w, h)) - } else { - rectBuf = encodeRawRect(img, pf, 0, 0, w, h)[4:] + body, done, err := s.encodeZlibSingle(img, pf, w, h, zlib, cursorRect) + if done { + return err } + rectBuf = body default: if cursorRect == nil { return s.writeFramed(encodeRawRect(img, pf, 0, 0, w, h)) @@ -478,11 +470,37 @@ func (s *session) sendFullUpdate(img *image.RGBA) error { return s.writeFramed(buf) } +// encodeZlibSingle encodes one full-frame rect with Zlib. When cursorRect is +// nil it writes the encodeZlibRect-baked FBU header directly and returns +// done=true with the writeFramed error. Otherwise it returns the rect body +// (header-stripped) so the caller can prepend a cursor rect. On compressor +// failure it falls back to Raw. +func (s *session) encodeZlibSingle(img *image.RGBA, pf clientPixelFormat, w, h int, zlib *zlibState, cursorRect []byte) (body []byte, done bool, err error) { + if zb, ok := encodeZlibRect(img, pf, 0, 0, w, h, zlib); ok { + if cursorRect == nil { + if werr := s.writeFramed(zb); werr != nil { + return nil, true, werr + } + return nil, true, nil + } + return zb[4:], false, nil + } + if cursorRect == nil { + if werr := s.writeFramed(encodeRawRect(img, pf, 0, 0, w, h)); werr != nil { + return nil, true, werr + } + return nil, true, nil + } + return encodeRawRect(img, pf, 0, 0, w, h)[4:], false, nil +} + func (s *session) writeFramed(buf []byte) error { s.writeMu.Lock() - _, err := s.conn.Write(buf) - s.writeMu.Unlock() - return err + defer s.writeMu.Unlock() + if _, err := s.conn.Write(buf); err != nil { + return err + } + return nil } // sendDirtyAndMoves writes one FramebufferUpdate combining CopyRect moves From 7cb6388349f968fe198d87791ddb2a9b66209bf2 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 23 May 2026 19:15:01 +0200 Subject: [PATCH 080/151] Decline VNC approval early when no console user is logged in --- client/vnc/server/console_user_darwin.go | 19 +++++++++++++++++++ client/vnc/server/console_user_other.go | 7 +++++++ client/vnc/server/console_user_windows.go | 13 +++++++++++++ client/vnc/server/server.go | 5 +++++ 4 files changed, 44 insertions(+) create mode 100644 client/vnc/server/console_user_darwin.go create mode 100644 client/vnc/server/console_user_other.go create mode 100644 client/vnc/server/console_user_windows.go diff --git a/client/vnc/server/console_user_darwin.go b/client/vnc/server/console_user_darwin.go new file mode 100644 index 00000000000..18091850acf --- /dev/null +++ b/client/vnc/server/console_user_darwin.go @@ -0,0 +1,19 @@ +package server + +import "errors" + +// consoleHasInteractiveUser returns true when a user is logged into the +// console (i.e. an Aqua session is active). At the loginwindow there is +// nobody to display an approval prompt to, so callers can decline +// without waiting on the broker. +func consoleHasInteractiveUser() bool { + if _, err := consoleUserID(); err != nil { + if errors.Is(err, errNoConsoleUser) { + return false + } + // Unknown error: fail closed so a probe-time glitch does not + // silently let an unattended console accept VNC sessions. + return false + } + return true +} diff --git a/client/vnc/server/console_user_other.go b/client/vnc/server/console_user_other.go new file mode 100644 index 00000000000..ddc67bfd4ff --- /dev/null +++ b/client/vnc/server/console_user_other.go @@ -0,0 +1,7 @@ +//go:build !darwin && !windows + +package server + +// consoleHasInteractiveUser is unused outside service mode (darwin/windows) +// but the symbol must exist so gateApproval compiles on all platforms. +func consoleHasInteractiveUser() bool { return true } diff --git a/client/vnc/server/console_user_windows.go b/client/vnc/server/console_user_windows.go new file mode 100644 index 00000000000..70197d8cc61 --- /dev/null +++ b/client/vnc/server/console_user_windows.go @@ -0,0 +1,13 @@ +package server + +// consoleHasInteractiveUser returns true when there is a logged-in user +// session on the box. At the lock/login screen WTSQueryUserName is empty, +// which means there is nobody to display an approval prompt to. Callers +// should decline without waiting on the broker in that case. +func consoleHasInteractiveUser() bool { + sid := getActiveSessionID() + if sid == 0 { + return false + } + return wtsSessionHasUser(sid) +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index f7c74ffdd5f..eb975101811 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -431,6 +431,11 @@ func (s *Server) gateApproval(conn net.Conn, header *connectionHeader, connLog * connLog.Warn("VNC connection rejected: approval required but no approver") return false, ApprovalDecision{} } + if s.serviceMode && !consoleHasInteractiveUser() { + rejectConnection(conn, codeMessage(RejectCodeNoConsoleUser, "no interactive user session")) + connLog.Info("VNC connection rejected: no interactive user session to approve") + return false, ApprovalDecision{} + } info := ApprovalInfo{ SourceIP: sourceIPString(conn.RemoteAddr()), Mode: modeString(header.mode), From fa57eedaf59a8c08eaa30e0c2b698b42d62887df Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 23 May 2026 19:44:21 +0200 Subject: [PATCH 081/151] Address CodeRabbit review and fix CI on embedded-vnc --- client/internal/debug/debug_test.go | 1 + client/internal/profilemanager/config.go | 2 +- client/server/capture.go | 12 +++- client/server/server.go | 8 ++- client/ui/approval.go | 8 ++- client/vnc/server/agent_windows.go | 6 +- client/vnc/server/console_user_darwin.go | 23 +++---- client/vnc/server/console_user_other.go | 6 +- client/vnc/server/console_user_windows.go | 16 ++--- client/vnc/server/server.go | 18 ++++-- client/vnc/server/server_windows.go | 1 - client/vnc/server/session.go | 12 +++- .../http/handlers/peers/peers_handler.go | 62 ++++++++++++------- 13 files changed, 108 insertions(+), 67 deletions(-) diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 5830583a332..2ed010354c7 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -863,6 +863,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { RosenpassPermissive: true, ServerSSHAllowed: &bTrue, ServerVNCAllowed: &bTrue, + DisableVNCApproval: &bTrue, EnableSSHRoot: &bTrue, EnableSSHSFTP: &bTrue, EnableSSHLocalPortForwarding: &bTrue, diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index a255a92c3c9..06ed7afa84e 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -433,7 +433,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } } else if config.ServerVNCAllowed == nil { - config.ServerVNCAllowed = util.True() + config.ServerVNCAllowed = util.False() updated = true } diff --git a/client/server/capture.go b/client/server/capture.go index 8975fdc7554..0b1eaa8e820 100644 --- a/client/server/capture.go +++ b/client/server/capture.go @@ -111,7 +111,7 @@ func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.Daemo return status.Errorf(codes.Internal, "create capture session: %v", err) } - engine, err := s.claimCapture(sess) + engine, err := s.claimCapture(sess, func() { pw.Close() }) if err != nil { sess.Stop() pw.Close() @@ -305,7 +305,7 @@ func (s *Server) cleanupBundleCapture() { // capture is already running it is evicted: a previous streaming session // whose gRPC client died and never freed the slot stays stuck otherwise, // and a bundle capture is just informational state. -func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) { +func (s *Server) claimCapture(sess *capture.Session, cancel func()) (*internal.Engine, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -315,6 +315,7 @@ func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) { return nil, err } s.activeCapture = sess + s.activeCaptureCancel = cancel return engine, nil } @@ -331,13 +332,18 @@ func (s *Server) evictActiveCaptureLocked() { } log.Infof("evicting previous streaming capture to start a new one") prev := s.activeCapture + cancel := s.activeCaptureCancel if engine, err := s.getCaptureEngineLocked(); err == nil { if err := engine.SetCapture(nil); err != nil { log.Debugf("clear previous capture: %v", err) } } s.activeCapture = nil + s.activeCaptureCancel = nil prev.Stop() + if cancel != nil { + cancel() + } } // releaseCapture clears the active-capture owner if it still matches sess. @@ -346,6 +352,7 @@ func (s *Server) releaseCapture(sess *capture.Session) { defer s.mutex.Unlock() if s.activeCapture == sess { s.activeCapture = nil + s.activeCaptureCancel = nil } } @@ -360,6 +367,7 @@ func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Eng log.Debugf("clear capture: %v", err) } s.activeCapture = nil + s.activeCaptureCancel = nil } func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) { diff --git a/client/server/server.go b/client/server/server.go index 1784c91a897..143bc817701 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -93,8 +93,12 @@ type Server struct { captureEnabled bool bundleCapture *bundleCapture // activeCapture is the session currently installed on the engine; guarded by s.mutex. - activeCapture *capture.Session - networksDisabled bool + activeCapture *capture.Session + // activeCaptureCancel tears down the streaming pipe/cancel for the + // active streaming capture so eviction unblocks the StartCapture RPC + // handler. Nil for bundle captures (they own their own context). + activeCaptureCancel func() + networksDisabled bool sleepHandler *sleephandler.SleepHandler diff --git a/client/ui/approval.go b/client/ui/approval.go index 70c8963d505..95e16882e5d 100644 --- a/client/ui/approval.go +++ b/client/ui/approval.go @@ -113,15 +113,17 @@ func (s *serviceClient) showApprovalUI(req approvalRequest) { decide(outcome{}) return } - updateCountdown() + fyne.Do(updateCountdown) } }() go func() { o := <-decided s.sendApprovalResponse(req.requestID, o.accept, o.viewOnly) - w.Close() - s.app.Quit() + fyne.Do(func() { + w.Close() + s.app.Quit() + }) }() w.Show() diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 735ab274d57..32c80383474 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -47,8 +47,6 @@ var ( procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW") procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory") procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW") - - iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") ) // GetCurrentSessionID returns the session ID of the current process. @@ -514,6 +512,8 @@ func (m *sessionManager) reapExitedAgent() { log.Debugf("close agent handle: %v", err) } m.agentProc = 0 + m.authToken = "" + m.socketPath = "" } // scheduleNextSpawn applies an exponential backoff on fast crashes (<5s) and @@ -586,6 +586,8 @@ func (m *sessionManager) killAgent() { _ = windows.TerminateProcess(m.agentProc, 0) _ = windows.CloseHandle(m.agentProc) m.agentProc = 0 + m.authToken = "" + m.socketPath = "" log.Info("killed old agent") } diff --git a/client/vnc/server/console_user_darwin.go b/client/vnc/server/console_user_darwin.go index 18091850acf..97df2b8a140 100644 --- a/client/vnc/server/console_user_darwin.go +++ b/client/vnc/server/console_user_darwin.go @@ -1,19 +1,10 @@ package server -import "errors" - -// consoleHasInteractiveUser returns true when a user is logged into the -// console (i.e. an Aqua session is active). At the loginwindow there is -// nobody to display an approval prompt to, so callers can decline -// without waiting on the broker. -func consoleHasInteractiveUser() bool { - if _, err := consoleUserID(); err != nil { - if errors.Is(err, errNoConsoleUser) { - return false - } - // Unknown error: fail closed so a probe-time glitch does not - // silently let an unattended console accept VNC sessions. - return false - } - return true +// interactiveUserError returns nil when a user is logged into the console +// (i.e. an Aqua session is active). At the loginwindow there is nobody to +// display an approval prompt to, so callers can decline without waiting on +// the broker. Any error (including errNoConsoleUser) is treated as decline. +func interactiveUserError() error { + _, err := consoleUserID() + return err } diff --git a/client/vnc/server/console_user_other.go b/client/vnc/server/console_user_other.go index ddc67bfd4ff..9824fd385cc 100644 --- a/client/vnc/server/console_user_other.go +++ b/client/vnc/server/console_user_other.go @@ -2,6 +2,6 @@ package server -// consoleHasInteractiveUser is unused outside service mode (darwin/windows) -// but the symbol must exist so gateApproval compiles on all platforms. -func consoleHasInteractiveUser() bool { return true } +// interactiveUserError is unused outside service mode (darwin/windows) but +// the symbol must exist so gateApproval compiles on all platforms. +func interactiveUserError() error { return nil } diff --git a/client/vnc/server/console_user_windows.go b/client/vnc/server/console_user_windows.go index 70197d8cc61..1356fb3cefe 100644 --- a/client/vnc/server/console_user_windows.go +++ b/client/vnc/server/console_user_windows.go @@ -1,13 +1,15 @@ package server -// consoleHasInteractiveUser returns true when there is a logged-in user -// session on the box. At the lock/login screen WTSQueryUserName is empty, -// which means there is nobody to display an approval prompt to. Callers -// should decline without waiting on the broker in that case. -func consoleHasInteractiveUser() bool { +// interactiveUserError returns nil when there is a logged-in user session +// on the box. At the lock/login screen WTSQueryUserName is empty, which +// means there is nobody to display an approval prompt to. +func interactiveUserError() error { sid := getActiveSessionID() if sid == 0 { - return false + return errNoConsoleUser } - return wtsSessionHasUser(sid) + if !wtsSessionHasUser(sid) { + return errNoConsoleUser + } + return nil } diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index eb975101811..daa274f66a4 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -431,10 +431,12 @@ func (s *Server) gateApproval(conn net.Conn, header *connectionHeader, connLog * connLog.Warn("VNC connection rejected: approval required but no approver") return false, ApprovalDecision{} } - if s.serviceMode && !consoleHasInteractiveUser() { - rejectConnection(conn, codeMessage(RejectCodeNoConsoleUser, "no interactive user session")) - connLog.Info("VNC connection rejected: no interactive user session to approve") - return false, ApprovalDecision{} + if s.serviceMode { + if err := interactiveUserError(); err != nil { + rejectConnection(conn, codeMessage(RejectCodeNoConsoleUser, "no interactive user session")) + connLog.Infof("VNC connection rejected: no interactive user session to approve: %v", err) + return false, ApprovalDecision{} + } } info := ApprovalInfo{ SourceIP: sourceIPString(conn.RemoteAddr()), @@ -449,7 +451,7 @@ func (s *Server) gateApproval(conn net.Conn, header *connectionHeader, connLog * } decision, err := s.approver.Request(s.ctx, info) if err != nil { - rejectConnection(conn, codeMessage(RejectCodeApprovalDenied, err.Error())) + rejectConnection(conn, codeMessage(RejectCodeApprovalDenied, "approval denied")) connLog.Infof("VNC connection rejected: approval %v", err) return false, ApprovalDecision{} } @@ -737,9 +739,13 @@ func (s *Server) validateCapturer(capturer ScreenCapturer) error { func (s *Server) isAllowedSource(addr net.Addr) bool { // Unix-socket remotes (the agent path) are local IPC, gated by the // token, not by overlay membership. + if _, ok := addr.(*net.UnixAddr); ok { + return true + } tcpAddr, ok := addr.(*net.TCPAddr) if !ok { - return true + s.log.Warnf("connection rejected: unsupported remote address type %T", addr) + return false } remoteIP, ok := netip.AddrFromSlice(tcpAddr.IP) diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index a8c58bd7f86..7ad88eef7f4 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -269,4 +269,3 @@ func (s *Server) serviceAcceptLoop() { }(conn) } } - diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 210e4379f3d..8a5d9bd31c3 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -545,6 +545,9 @@ func (s *session) handleFBUpdateRequest() error { // in sync with the active session (e.g. username changes after login on // a virtual session). func (s *session) SendDesktopName(name string) error { + if s.viewOnly { + name = ViewOnlyDesktopNamePrefix + name + } s.encMu.RLock() supported := s.clientSupportsDesktopName s.encMu.RUnlock() @@ -629,13 +632,13 @@ func (s *session) handlePointerEvent() error { mask = (mask & 0x7f) | uint16(hi[0])<<7 } + if s.viewOnly { + return nil + } s.pointerMu.Lock() s.lastPointerX = x s.lastPointerY = y s.pointerMu.Unlock() - if s.viewOnly { - return nil - } s.injector.InjectPointer(mask, x, y, s.serverW, s.serverH) return nil } @@ -661,6 +664,9 @@ var stickyModifierKeysyms = [...]uint32{ // when the client disconnects mid-press. Mouse coordinates are reused // from the last PointerEvent so we don't warp the cursor. func (s *session) releaseStickyInput() { + if s.viewOnly { + return + } for _, ks := range stickyModifierKeysyms { s.injector.InjectKey(ks, false) } diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 8e7385f7a98..8d410de77f9 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -475,6 +475,37 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) newPeer := &nbpeer.Peer{} newPeer.FromAPITemporaryAccessRequest(&req) + parsedRules := make([]struct { + raw string + protocol types.PolicyRuleProtocolType + portRange types.RulePortRange + }, 0, len(req.Rules)) + needsVNCKey := false + for _, rule := range req.Rules { + protocol, portRange, err := types.ParseRuleString(rule) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + if protocol == types.PolicyRuleProtocolNetbirdVNC { + needsVNCKey = true + } + parsedRules = append(parsedRules, struct { + raw string + protocol types.PolicyRuleProtocolType + portRange types.RulePortRange + }{rule, protocol, portRange}) + } + + var vncSessionPubKey string + if needsVNCKey { + vncSessionPubKey, err = validateVNCSessionPubKey(req.SessionPubKey) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + } + targetPeer, err := h.accountManager.GetPeer(r.Context(), userAuth.AccountId, peerID, userAuth.UserId) if err != nil { util.WriteError(r.Context(), err, w) @@ -487,12 +518,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } - for _, rule := range req.Rules { - protocol, portRange, err := types.ParseRuleString(rule) - if err != nil { - util.WriteError(r.Context(), err, w) - return - } + for _, pr := range parsedRules { policy := &types.Policy{ AccountID: userAuth.AccountId, Description: "Temporary access policy for peer " + peer.Name, @@ -512,34 +538,28 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) ID: targetPeer.ID, }, Bidirectional: false, - Protocol: protocol, - PortRanges: []types.RulePortRange{portRange}, + Protocol: pr.protocol, + PortRanges: []types.RulePortRange{pr.portRange}, }}, } - if protocol == types.PolicyRuleProtocolNetbirdSSH || protocol == types.PolicyRuleProtocolNetbirdVNC { + if pr.protocol == types.PolicyRuleProtocolNetbirdSSH || pr.protocol == types.PolicyRuleProtocolNetbirdVNC { policy.Rules[0].AuthorizedUser = userAuth.UserId } - if protocol == types.PolicyRuleProtocolNetbirdVNC { - pubKey, err := validateVNCSessionPubKey(req.SessionPubKey) - if err != nil { - util.WriteError(r.Context(), err, w) - return - } - policy.Rules[0].SessionPubKey = pubKey + if pr.protocol == types.PolicyRuleProtocolNetbirdVNC { + policy.Rules[0].SessionPubKey = vncSessionPubKey policy.Rules[0].SessionDisplayName = h.displayNameForUser(r.Context(), userAuth) } - _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true) - if err != nil { + if _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true); err != nil { util.WriteError(r.Context(), err, w) return } } resp := &api.PeerTemporaryAccessResponse{ - Id: peer.ID, - Name: peer.Name, - Rules: req.Rules, + Id: peer.ID, + Name: peer.Name, + Rules: req.Rules, TargetPubKey: targetPeer.Key, } From f557e665a5dbeb9f1466044bf8591d508890d9c5 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sat, 23 May 2026 19:50:27 +0200 Subject: [PATCH 082/151] Return error from gateApproval and log at the caller --- client/vnc/server/agent_ipc.go | 10 ++++++++-- client/vnc/server/server.go | 34 +++++++++++++++++--------------- client/vnc/server/server_test.go | 15 +++++++++----- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index dc2e96a4765..abe903d9ea4 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -72,10 +72,16 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { } s.registerConnAuth(conn, header) - allow, decision := s.gateApproval(conn, header, authedLog) - if !allow { + decision, err := s.gateApproval(conn, header) + if err != nil { + authedLog.Infof("VNC connection rejected: %v", err) return } + if decision.ViewOnly { + authedLog.Info("VNC connection approved by user (view-only)") + } else if s.requireApproval { + authedLog.Info("VNC connection approved by user") + } socketPath, token, err := sa.Resolve(s.ctx) if err != nil { diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index daa274f66a4..0f137d3c576 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -422,20 +422,22 @@ func (s *Server) untrackConn(c net.Conn) { // gateApproval prompts the local user to accept or deny conn before any // session resources are allocated. On rejection the conn already received // an RFB reject reason; the gate does not close it. -func (s *Server) gateApproval(conn net.Conn, header *connectionHeader, connLog *log.Entry) (bool, ApprovalDecision) { +// gateApproval returns the user's decision when approval is enabled, or a +// zero decision when it isn't. On rejection it writes the RFB rejection +// message to conn and returns an error; the caller is responsible for +// logging it (this function does not log on its own). +func (s *Server) gateApproval(conn net.Conn, header *connectionHeader) (ApprovalDecision, error) { if !s.requireApproval { - return true, ApprovalDecision{} + return ApprovalDecision{}, nil } if s.approver == nil { rejectConnection(conn, codeMessage(RejectCodeNoApprover, "approval required but no approver configured")) - connLog.Warn("VNC connection rejected: approval required but no approver") - return false, ApprovalDecision{} + return ApprovalDecision{}, errors.New("approval required but no approver configured") } if s.serviceMode { if err := interactiveUserError(); err != nil { rejectConnection(conn, codeMessage(RejectCodeNoConsoleUser, "no interactive user session")) - connLog.Infof("VNC connection rejected: no interactive user session to approve: %v", err) - return false, ApprovalDecision{} + return ApprovalDecision{}, fmt.Errorf("no interactive user session: %w", err) } } info := ApprovalInfo{ @@ -452,15 +454,9 @@ func (s *Server) gateApproval(conn net.Conn, header *connectionHeader, connLog * decision, err := s.approver.Request(s.ctx, info) if err != nil { rejectConnection(conn, codeMessage(RejectCodeApprovalDenied, "approval denied")) - connLog.Infof("VNC connection rejected: approval %v", err) - return false, ApprovalDecision{} + return ApprovalDecision{}, fmt.Errorf("approval: %w", err) } - if decision.ViewOnly { - connLog.Info("VNC connection approved by user (view-only)") - } else { - connLog.Info("VNC connection approved by user") - } - return true, decision + return decision, nil } // sourceIPString returns the IP portion of a remote address, or the full @@ -804,10 +800,16 @@ func (s *Server) handleConnection(conn net.Conn) { } s.registerConnAuth(conn, header) - allow, decision := s.gateApproval(conn, header, connLog) - if !allow { + decision, err := s.gateApproval(conn, header) + if err != nil { + connLog.Infof("VNC connection rejected: %v", err) return } + if decision.ViewOnly { + connLog.Info("VNC connection approved by user (view-only)") + } else if s.requireApproval { + connLog.Info("VNC connection approved by user") + } capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog) if !ok { diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index a8a6dffb861..a820469e677 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -405,7 +405,8 @@ func TestGateApproval_Disabled_NoApproverCall(t *testing.T) { defer conn.Close() header := &connectionHeader{mode: ModeAttach} - allowed, _ := srv.gateApproval(conn, header, srv.log) + _, err := srv.gateApproval(conn, header) + allowed := err == nil assert.True(t, allowed, "gate must pass through when requireApproval is false") assert.Equal(t, int32(0), app.calls.Load(), "approver must not be called when disabled") } @@ -439,7 +440,8 @@ func TestGateApproval_Enabled_NilApproverDenies(t *testing.T) { }() header := &connectionHeader{mode: ModeAttach} - allowed, _ := srv.gateApproval(srvConn, header, srv.log) + _, err := srv.gateApproval(srvConn, header) + allowed := err == nil assert.False(t, allowed, "missing approver MUST deny; never silently pass") select { @@ -472,7 +474,8 @@ func TestGateApproval_ApproverDenies(t *testing.T) { defer conn.Close() header := &connectionHeader{mode: ModeAttach} - allowed, _ := srv.gateApproval(conn, header, srv.log) + _, err := srv.gateApproval(conn, header) + allowed := err == nil assert.False(t, allowed, "approver error %v must deny", tc.err) assert.Equal(t, int32(1), app.calls.Load()) }) @@ -489,7 +492,8 @@ func TestGateApproval_ApproverAccepts(t *testing.T) { defer conn.Close() header := &connectionHeader{mode: ModeAttach, username: "alice"} - allowed, _ := srv.gateApproval(conn, header, srv.log) + _, err := srv.gateApproval(conn, header) + allowed := err == nil assert.True(t, allowed, "approver returning nil must let the gate pass") assert.Equal(t, int32(1), app.calls.Load()) assert.Equal(t, "alice", app.lastIn.Username, "header username must reach the approver") @@ -510,7 +514,8 @@ func TestGateApproval_PassesPubKeyHex(t *testing.T) { pub[i] = byte(i) } header := &connectionHeader{mode: ModeAttach, clientStatic: pub} - allowed, _ := srv.gateApproval(conn, header, srv.log) + _, err := srv.gateApproval(conn, header) + allowed := err == nil assert.True(t, allowed) assert.Equal(t, hex.EncodeToString(pub), app.lastIn.PeerPubKey) } From 5e2830be8a2942cfdc09638ab8a08f10573da027 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 24 May 2026 16:02:36 +0200 Subject: [PATCH 083/151] Harden VNC server, IPC, and management plumbing --- client/cmd/vnc_agent.go | 18 +- client/cmd/vnc_agent_dropprivs_darwin.go | 50 +++ client/cmd/vnc_agent_dropprivs_darwin_test.go | 55 +++ .../vnc_agent_dropprivs_testhelpers_darwin.go | 11 + client/cmd/vnc_agent_dropprivs_windows.go | 14 + client/internal/approval/broker.go | 31 ++ client/internal/approval/fingerprint_test.go | 62 +++ client/ui/approval.go | 28 +- client/ui/client_ui.go | 35 +- client/vnc/server/agent_darwin.go | 107 ++++- client/vnc/server/agent_ipc.go | 53 ++- client/vnc/server/agent_peercred_darwin.go | 46 +++ .../vnc/server/agent_peercred_darwin_test.go | 115 ++++++ client/vnc/server/agent_peercred_windows.go | 19 + client/vnc/server/agent_windows.go | 18 +- client/vnc/server/copyrect.go | 14 +- client/vnc/server/cursor_windows.go | 3 + client/vnc/server/handshake.go | 43 +- client/vnc/server/noise_auth_test.go | 36 +- client/vnc/server/rfb.go | 43 +- client/vnc/server/security_hardening_test.go | 374 ++++++++++++++++++ client/vnc/server/server.go | 45 ++- client/vnc/server/session_cursor.go | 40 +- client/vnc/server/session_encode.go | 26 +- client/wasm/internal/vnc/proxy.go | 21 + .../http/handlers/peers/peers_handler.go | 20 + .../peers/temporary_access_permission_test.go | 106 +++++ .../server/types/policy_authorized_users.go | 10 +- .../policy_authorized_users_security_test.go | 85 ++++ 29 files changed, 1428 insertions(+), 100 deletions(-) create mode 100644 client/cmd/vnc_agent_dropprivs_darwin.go create mode 100644 client/cmd/vnc_agent_dropprivs_darwin_test.go create mode 100644 client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go create mode 100644 client/cmd/vnc_agent_dropprivs_windows.go create mode 100644 client/internal/approval/fingerprint_test.go create mode 100644 client/vnc/server/agent_peercred_darwin.go create mode 100644 client/vnc/server/agent_peercred_darwin_test.go create mode 100644 client/vnc/server/agent_peercred_windows.go create mode 100644 client/vnc/server/security_hardening_test.go create mode 100644 management/server/http/handlers/peers/temporary_access_permission_test.go create mode 100644 management/server/types/policy_authorized_users_security_test.go diff --git a/client/cmd/vnc_agent.go b/client/cmd/vnc_agent.go index 742b498ba41..e8993baa6ec 100644 --- a/client/cmd/vnc_agent.go +++ b/client/cmd/vnc_agent.go @@ -14,10 +14,14 @@ import ( vncserver "github.com/netbirdio/netbird/client/vnc/server" ) -var vncAgentSocket string +var ( + vncAgentSocket string + vncAgentTargetUID uint32 +) func init() { vncAgentCmd.Flags().StringVar(&vncAgentSocket, "socket", "", "Unix-domain socket path the agent listens on (required)") + vncAgentCmd.Flags().Uint32Var(&vncAgentTargetUID, "target-uid", 0, "uid the agent should drop privileges to before listening (darwin only; 0 = stay as current uid)") rootCmd.AddCommand(vncAgentCmd) } @@ -47,6 +51,18 @@ var vncAgentCmd = &cobra.Command{ log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err) } + // Drop root privileges to the target console user BEFORE creating + // the listening socket: keeps a post-auth bug in the encoder / + // input / capture paths confined to the user's own privileges + // rather than escalating to host root, and makes the daemon's + // LOCAL_PEERCRED check see the right uid. No-op on Windows + // (both processes run as SYSTEM) and when --target-uid is 0. + if vncAgentTargetUID != 0 { + if err := dropAgentPrivileges(vncAgentTargetUID); err != nil { + return fmt.Errorf("drop privileges to uid %d: %w", vncAgentTargetUID, err) + } + } + if err := os.Remove(vncAgentSocket); err != nil && !os.IsNotExist(err) { log.Debugf("remove stale socket %s: %v", vncAgentSocket, err) } diff --git a/client/cmd/vnc_agent_dropprivs_darwin.go b/client/cmd/vnc_agent_dropprivs_darwin.go new file mode 100644 index 00000000000..2e0f080da0a --- /dev/null +++ b/client/cmd/vnc_agent_dropprivs_darwin.go @@ -0,0 +1,50 @@ +//go:build darwin && !ios + +package cmd + +import ( + "fmt" + "os" + "syscall" +) + +// dropAgentPrivileges drops the vnc-agent process from root (its +// launchctl-asuser-inherited starting uid) to the target console user +// before any other initialisation runs. Without this the agent runs as +// root for the lifetime of the session; any post-auth memory-safety +// issue in the capture/input/encode paths would then be a root-level +// RCE on the host instead of a user-level one. Also makes the daemon's +// LOCAL_PEERCRED check correctly identify the agent as the console user, +// not as root. +// +// Returns an error when the agent is running as a non-root uid that +// differs from targetUID: non-root can only setuid to itself, so a +// mismatch here means the spawn went to the wrong session. +func dropAgentPrivileges(targetUID uint32) error { + if targetUID == 0 { + return fmt.Errorf("refusing to keep agent running as root (target uid 0)") + } + cur := uint32(os.Getuid()) + if cur == targetUID { + return nil + } + if cur != 0 { + return fmt.Errorf("agent uid %d does not match expected %d and we lack root to fix it", cur, targetUID) + } + // Drop supplementary groups first: setgid alone doesn't touch the + // auxiliary group list, leaving root's groups attached would let the + // dropped process write to root-only group-writable files. + if err := syscall.Setgroups([]int{}); err != nil { + return fmt.Errorf("setgroups([]): %w", err) + } + if err := syscall.Setgid(int(targetUID)); err != nil { + return fmt.Errorf("setgid(%d): %w", targetUID, err) + } + if err := syscall.Setuid(int(targetUID)); err != nil { + return fmt.Errorf("setuid(%d): %w", targetUID, err) + } + if uint32(os.Getuid()) != targetUID || uint32(os.Geteuid()) != targetUID { + return fmt.Errorf("setuid verification: uid=%d euid=%d, expected %d", os.Getuid(), os.Geteuid(), targetUID) + } + return nil +} diff --git a/client/cmd/vnc_agent_dropprivs_darwin_test.go b/client/cmd/vnc_agent_dropprivs_darwin_test.go new file mode 100644 index 00000000000..3d650f0a853 --- /dev/null +++ b/client/cmd/vnc_agent_dropprivs_darwin_test.go @@ -0,0 +1,55 @@ +//go:build darwin && !ios + +package cmd + +import ( + "strings" + "testing" +) + +// TestDropAgentPrivileges_RefusesRootTarget locks in the contract that +// dropAgentPrivileges must never be a no-op when asked to keep the +// agent as root (target uid 0). A future caller that passes 0 by +// mistake would otherwise leave the post-auth attack surface running +// with full root privileges. +func TestDropAgentPrivileges_RefusesRootTarget(t *testing.T) { + err := dropAgentPrivileges(0) + if err == nil { + t.Fatal("expected refusal for target uid 0, got nil") + } + if !strings.Contains(err.Error(), "root") { + t.Fatalf("error should mention root, got: %v", err) + } +} + +// TestDropAgentPrivileges_NoOpWhenAlreadyTarget covers the dev path +// where the agent is launched by hand as the target user (no root +// available, no setuid needed). The helper must succeed silently +// instead of trying (and failing) a setuid to its current uid. +func TestDropAgentPrivileges_NoOpWhenAlreadyTarget(t *testing.T) { + // Skip when running as root: the early-return path we want to + // cover only fires when current uid == target uid. + uid := currentUIDForTest() + if uid == 0 { + t.Skip("test must not run as root; cannot exercise the no-op early-return") + } + if err := dropAgentPrivileges(uid); err != nil { + t.Fatalf("expected no-op when current uid == target, got: %v", err) + } +} + +// TestDropAgentPrivileges_RefusesMismatchedNonRoot guards the "non-root +// caller tries to setuid to a different uid" path: setuid would fail +// with EPERM anyway, but the helper should surface a clear error +// before issuing the syscall so a misconfigured spawn (wrong --target-uid +// flag) is debuggable. +func TestDropAgentPrivileges_RefusesMismatchedNonRoot(t *testing.T) { + uid := currentUIDForTest() + if uid == 0 { + t.Skip("test must not run as root; covered case requires non-root caller") + } + err := dropAgentPrivileges(uid + 1) + if err == nil { + t.Fatal("expected refusal when non-root caller asks to setuid elsewhere") + } +} diff --git a/client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go b/client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go new file mode 100644 index 00000000000..d4e895b2ac7 --- /dev/null +++ b/client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go @@ -0,0 +1,11 @@ +//go:build darwin && !ios + +package cmd + +import "os" + +// currentUIDForTest exposes os.Getuid for the darwin dropprivs tests +// without leaking an os import into the test file itself. +func currentUIDForTest() uint32 { + return uint32(os.Getuid()) +} diff --git a/client/cmd/vnc_agent_dropprivs_windows.go b/client/cmd/vnc_agent_dropprivs_windows.go new file mode 100644 index 00000000000..7e63537408f --- /dev/null +++ b/client/cmd/vnc_agent_dropprivs_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package cmd + +// dropAgentPrivileges is a no-op on Windows: the agent and the daemon +// both run as SYSTEM (the daemon spawns the agent into the interactive +// session via CreateProcessAsUser with an impersonation token, but the +// resulting process still runs under SYSTEM, not under the user's +// account). The Windows path relies on the C:\Windows\Temp socket +// location (admin/SYSTEM-write-only) and the per-spawn token for +// integrity instead. +func dropAgentPrivileges(_ uint32) error { + return nil +} diff --git a/client/internal/approval/broker.go b/client/internal/approval/broker.go index 0cc0d95148d..08eaf72f6a0 100644 --- a/client/internal/approval/broker.go +++ b/client/internal/approval/broker.go @@ -28,6 +28,37 @@ const ( MetaExpiresAt = "expires_at" ) +// ShortKeyFingerprint formats a hex-encoded Noise_IK static pubkey as a +// short, eyeball-able fingerprint to display in the approval dialog. +// The dashboard-supplied display name attached to a SessionPubKey isn't +// cryptographically asserted by the connecting client, so the prompt +// must also show something that IS: the key fingerprint, a hash of +// the static public key the client just proved possession of during the +// Noise handshake. Returns the empty string when the input is too short +// to plausibly be a hex pubkey, so the row is omitted rather than +// rendered as a misleading partial. +// +// Output format: 16 hex chars grouped as XXXX-XXXX-XXXX-XXXX (64 bits of +// fingerprint, resistant to random-prefix collisions and easy for a human +// to compare with an out-of-band reference). +func ShortKeyFingerprint(hexKey string) string { + if len(hexKey) < 8 { + return "" + } + src := hexKey + if len(src) > 16 { + src = src[:16] + } + var out []byte + for i, c := range src { + if i > 0 && i%4 == 0 { + out = append(out, '-') + } + out = append(out, byte(c)) + } + return string(out) +} + // Kind values for the well-known prompt subjects. New subsystems should // add a constant here so the UI can dispatch on a known string. const ( diff --git a/client/internal/approval/fingerprint_test.go b/client/internal/approval/fingerprint_test.go new file mode 100644 index 00000000000..691b2289e91 --- /dev/null +++ b/client/internal/approval/fingerprint_test.go @@ -0,0 +1,62 @@ +package approval + +import "testing" + +// TestShortKeyFingerprint locks in the format the VNC approval prompt +// shows to the user. The fingerprint is the user's only cryptographic +// anchor against a malicious management server that pushes a spoofed +// display name, so accidental changes to its format would silently +// undermine that defence. +func TestShortKeyFingerprint(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "full_32_byte_pubkey", + in: "0123456789abcdeffedcba9876543210ffeeddccbbaa99887766554433221100", + want: "0123-4567-89ab-cdef", + }, + { + name: "exactly_16_chars", + in: "0123456789abcdef", + want: "0123-4567-89ab-cdef", + }, + { + name: "borderline_8_chars", + in: "01234567", + want: "0123-4567", + }, + { + name: "too_short_returns_empty", + in: "0123", + want: "", + }, + { + name: "empty_returns_empty", + in: "", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ShortKeyFingerprint(tc.in) + if got != tc.want { + t.Fatalf("ShortKeyFingerprint(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestShortKeyFingerprint_DistinctKeysDistinctOutputs guards against a +// formatting bug that would collapse different prefixes onto the same +// displayed fingerprint and let an attacker substitute their pubkey for +// a victim's while keeping the prompt visually identical. +func TestShortKeyFingerprint_DistinctKeysDistinctOutputs(t *testing.T) { + a := ShortKeyFingerprint("0123456789abcdef" + "rest_of_pubkey_ignored") + b := ShortKeyFingerprint("0123456789abcde0" + "rest_of_pubkey_ignored") + if a == b { + t.Fatalf("expected distinct outputs for distinct prefixes, both = %q", a) + } +} diff --git a/client/ui/approval.go b/client/ui/approval.go index 95e16882e5d..7c99585f9fc 100644 --- a/client/ui/approval.go +++ b/client/ui/approval.go @@ -13,6 +13,7 @@ import ( "fyne.io/fyne/v2/widget" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/internal/approval" "github.com/netbirdio/netbird/client/proto" ) @@ -38,6 +39,7 @@ func (s *serviceClient) handleApprovalEvent(ev *proto.SystemEvent) { "--approval-source-ip=" + ev.Metadata["source_ip"], "--approval-username=" + ev.Metadata["username"], "--approval-expires-at=" + ev.Metadata["expires_at"], + "--approval-key-fingerprint=" + ev.Metadata["peer_pubkey"], "--approval-subject=" + ev.UserMessage, } go s.eventHandler.runSelfCommand(s.ctx, "approval", args...) @@ -53,8 +55,17 @@ func (s *serviceClient) showApprovalUI(req approvalRequest) { var rows []string if req.initiator != "" { + // The display name comes from the management dashboard and is + // not cryptographically asserted by the connecting client. The + // key fingerprint that follows IS: it's the Noise_IK static + // public key the client just proved possession of. Show both + // so the user can sanity-check that "Alice" is really the + // Alice they trust. rows = append(rows, "From user: "+req.initiator) } + if fp := approval.ShortKeyFingerprint(req.keyFingerprint); fp != "" { + rows = append(rows, "Key fp: "+fp) + } if req.peerName != "" { rows = append(rows, "Via peer: "+req.peerName) } @@ -149,14 +160,15 @@ func (s *serviceClient) sendApprovalResponse(requestID string, accept, viewOnly // approvalRequest is the parsed --approval-* CLI args that the forked // dialog process consumes. type approvalRequest struct { - requestID string - kind string - initiator string - peerName string - sourceIP string - username string - subject string - expiresAt string + requestID string + kind string + initiator string + peerName string + sourceIP string + username string + subject string + expiresAt string + keyFingerprint string } func (r approvalRequest) displayPeer() string { diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index f05e8dceb1a..18ca9d08be8 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -99,14 +99,15 @@ func main() { showUpdateVersion: flags.showUpdateVersion, showApproval: flags.showApproval, approvalRequest: approvalRequest{ - requestID: flags.approvalRequestID, - kind: flags.approvalKind, - initiator: flags.approvalInitiator, - peerName: flags.approvalPeerName, - sourceIP: flags.approvalSourceIP, - username: flags.approvalUsername, - subject: flags.approvalSubject, - expiresAt: flags.approvalExpiresAt, + requestID: flags.approvalRequestID, + kind: flags.approvalKind, + initiator: flags.approvalInitiator, + peerName: flags.approvalPeerName, + sourceIP: flags.approvalSourceIP, + username: flags.approvalUsername, + subject: flags.approvalSubject, + expiresAt: flags.approvalExpiresAt, + keyFingerprint: flags.approvalKeyFingerprint, }, }) @@ -153,14 +154,15 @@ type cliFlags struct { showUpdateVersion string showApproval bool - approvalRequestID string - approvalKind string - approvalInitiator string - approvalPeerName string - approvalSourceIP string - approvalUsername string - approvalSubject string - approvalExpiresAt string + approvalRequestID string + approvalKind string + approvalInitiator string + approvalPeerName string + approvalSourceIP string + approvalUsername string + approvalSubject string + approvalExpiresAt string + approvalKeyFingerprint string } // parseFlags reads and returns all needed command-line flags. @@ -191,6 +193,7 @@ func parseFlags() *cliFlags { flag.StringVar(&flags.approvalUsername, "approval-username", "", "approval prompt: requested OS username") flag.StringVar(&flags.approvalSubject, "approval-subject", "", "approval prompt: human-readable subject line") flag.StringVar(&flags.approvalExpiresAt, "approval-expires-at", "", "approval prompt: RFC3339 deadline at which the daemon auto-denies") + flag.StringVar(&flags.approvalKeyFingerprint, "approval-key-fingerprint", "", "approval prompt: hex-encoded Noise static pubkey of the connecting client") flag.Parse() return &flags } diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index edaf5bdf16b..3c470f9daf7 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -43,11 +43,11 @@ func newDarwinAgentManager(ctx context.Context) *darwinAgentManager { return m } -// agentSocketPathFmt parameterizes the agent's loopback Unix-socket path -// by the console uid: /tmp is writable in the launchctl-asuser context -// and predictable to the daemon. The agent chmods the file 0600 after -// bind so only its uid (plus root) can dial. -const agentSocketPathFmt = "/tmp/netbird-vnc-%d.sock" +// agentSocketName is the file name inside the per-uid socket directory +// the agent binds. The directory itself is created and chowned by the +// daemon (see prepareAgentSocketDir) so a non-root local user cannot +// pre-create or symlink the path before the agent listens. +const agentSocketName = "agent.sock" // watchConsoleUser kills the cached agent whenever the console user // changes (logout, fast user switch, login window). Without it the daemon @@ -87,44 +87,112 @@ func (m *darwinAgentManager) watchConsoleUser(ctx context.Context) { } // Resolve spawns or respawns the per-user agent process as needed and -// returns its Unix-socket path and shared token. Each call is serialized -// so concurrent VNC clients share the same agent. -func (m *darwinAgentManager) Resolve(ctx context.Context) (string, string, error) { +// returns its Unix-socket path, shared token, and the uid the agent was +// spawned under (so the daemon can validate peer credentials before +// dispatching the token). Each call is serialized so concurrent VNC +// clients share the same agent. +func (m *darwinAgentManager) Resolve(ctx context.Context) (string, string, uint32, error) { consoleUID, err := consoleUserID() if err != nil { - return "", "", fmt.Errorf("no console user: %w", err) + return "", "", 0, fmt.Errorf("no console user: %w", err) } m.mu.Lock() defer m.mu.Unlock() if m.running && m.uid == consoleUID && vncAgentRunning() { - return m.socketPath, m.authToken, nil + return m.socketPath, m.authToken, m.uid, nil } m.killLocked() // Reap stray agents so the new token is the only accepted one. killAllVNCAgents() - socketPath := fmt.Sprintf(agentSocketPathFmt, consoleUID) + socketDir, err := prepareAgentSocketDir(consoleUID) + if err != nil { + return "", "", 0, fmt.Errorf("prepare agent socket dir: %w", err) + } + socketPath := socketDir + "/" + agentSocketName if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { log.Debugf("clear stale agent socket %s: %v", socketPath, err) } token, err := generateAuthToken() if err != nil { - return "", "", fmt.Errorf("generate agent auth token: %w", err) + return "", "", 0, fmt.Errorf("generate agent auth token: %w", err) } if err := spawnAgentForUser(consoleUID, socketPath, token); err != nil { - return "", "", err + return "", "", 0, err } if err := waitForAgent(ctx, socketPath, 5*time.Second); err != nil { killAllVNCAgents() - return "", "", fmt.Errorf("agent did not start listening: %w", err) + return "", "", 0, fmt.Errorf("agent did not start listening: %w", err) } m.authToken = token m.socketPath = socketPath m.uid = consoleUID m.running = true log.Infof("spawned VNC agent for console uid=%d on %s", consoleUID, socketPath) - return socketPath, token, nil + return socketPath, token, consoleUID, nil +} + +// agentSocketParentDir is the root the daemon creates (as root, mode 0755) +// to hold per-uid agent-socket subdirectories. Keeping it under +// /var/run/netbird-vnc (rather than /tmp) means a non-root local user +// cannot squat the socket path: only root can create the parent, and +// only the target user (plus root) can write inside the per-uid subdir. +const agentSocketParentDir = "/var/run/netbird-vnc" + +// prepareAgentSocketDir creates (and tightens permissions on) a per-uid +// subdirectory the agent will bind its socket inside, returning the +// directory path. The subdirectory is owned by uid with mode 0700, so +// the only writers are the target user and root. The parent is created +// root-owned with mode 0755 if it doesn't already exist. Symlinks at +// the per-uid level are refused (replaced with a fresh directory) to +// avoid a low-priv user redirecting our chown. +func prepareAgentSocketDir(uid uint32) (string, error) { + if err := os.MkdirAll(agentSocketParentDir, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", agentSocketParentDir, err) + } + // Refuse to use the parent if it's a symlink or not owned by root. + pInfo, err := os.Lstat(agentSocketParentDir) + if err != nil { + return "", fmt.Errorf("lstat %s: %w", agentSocketParentDir, err) + } + if pInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s is a symlink", agentSocketParentDir) + } + if st, ok := pInfo.Sys().(*syscall.Stat_t); ok && st.Uid != 0 { + return "", fmt.Errorf("%s not owned by root (uid=%d)", agentSocketParentDir, st.Uid) + } + + subdir := fmt.Sprintf("%s/%d", agentSocketParentDir, uid) + // If a leftover entry exists, refuse it unless it's a real dir owned + // by the right uid with strict perms: otherwise remove and recreate + // from scratch under our control. Using os.Lstat (not Stat) so a + // symlink is detected and torn down. + if info, err := os.Lstat(subdir); err == nil { + bad := false + if info.Mode()&os.ModeSymlink != 0 { + bad = true + } else if !info.IsDir() { + bad = true + } else if st, ok := info.Sys().(*syscall.Stat_t); !ok || st.Uid != uid || info.Mode().Perm() != 0o700 { + bad = true + } + if bad { + if err := os.RemoveAll(subdir); err != nil { + return "", fmt.Errorf("remove stale %s: %w", subdir, err) + } + } + } + if err := os.Mkdir(subdir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return "", fmt.Errorf("mkdir %s: %w", subdir, err) + } + if err := os.Chmod(subdir, 0o700); err != nil { + return "", fmt.Errorf("chmod %s: %w", subdir, err) + } + if err := os.Chown(subdir, int(uid), -1); err != nil { + return "", fmt.Errorf("chown %s -> uid %d: %w", subdir, uid, err) + } + return subdir, nil } // stop terminates the spawned agent, if any. Intended for daemon shutdown. @@ -182,7 +250,14 @@ func spawnAgentForUser(uid uint32, socketPath, token string) error { } cmd := exec.Command( "/bin/launchctl", "asuser", strconv.FormatUint(uint64(uid), 10), - exe, vncAgentSubcommand, "--socket", socketPath, + exe, vncAgentSubcommand, + "--socket", socketPath, + // Drop privs inside the agent: launchctl asuser preserves the + // daemon's uid (root), so without this the capture/input/ + // encoder paths would run as root for the lifetime of the + // session. validateAgentPeer on the daemon side also relies on + // the agent's effective uid matching consoleUID. + "--target-uid", strconv.FormatUint(uint64(uid), 10), ) cmd.Env = append(os.Environ(), agentTokenEnvVar+"="+token) stderr, err := cmd.StderrPipe() diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index abe903d9ea4..aba3e7da5b5 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -26,9 +26,12 @@ var errNoConsoleUser = errors.New("no user logged into console") // sessionAgent abstracts the per-platform manager that spawns and tracks // the user-session VNC agent. Resolve returns the agent's Unix-socket -// path and shared token, possibly spawning lazily. +// path, the shared per-spawn token, and the uid the agent was spawned +// under (used to validate peer credentials before the daemon hands the +// token to whoever is on the other end of the socket). Resolve may spawn +// the agent lazily. type sessionAgent interface { - Resolve(ctx context.Context) (socketPath, token string, err error) + Resolve(ctx context.Context) (socketPath, token string, peerUID uint32, err error) } // prefixConn replays already-consumed header bytes ahead of the proxy @@ -70,7 +73,11 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { authedLog.Info("VNC connection rejected: auth failed") return } - s.registerConnAuth(conn, header) + if err := s.registerConnAuth(conn, header); err != nil { + rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) + authedLog.Warnf("VNC connection rejected: %v", err) + return + } decision, err := s.gateApproval(conn, header) if err != nil { @@ -83,7 +90,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { authedLog.Info("VNC connection approved by user") } - socketPath, token, err := sa.Resolve(s.ctx) + socketPath, token, peerUID, err := sa.Resolve(s.ctx) if err != nil { code := RejectCodeCapturerError if errors.Is(err, errNoConsoleUser) { @@ -98,7 +105,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { Reader: io.MultiReader(&headerBuf, conn), Conn: conn, } - if err := proxyToAgent(s.ctx, replayConn, socketPath, token, decision.ViewOnly); err != nil { + if err := proxyToAgent(s.ctx, replayConn, socketPath, token, peerUID, decision.ViewOnly, authedLog); err != nil { rejectConnection(conn, codeMessage(RejectCodeCapturerError, err.Error())) authedLog.Warnf("VNC connection rejected: agent unreachable: %v", err) return @@ -134,14 +141,18 @@ func generateAuthToken() (string, error) { return hex.EncodeToString(b), nil } -// proxyToAgent dials the per-session agent's Unix socket, writes the -// raw token bytes plus a single view-only flag byte, then copies bytes -// both ways until either side closes. The token + flag prefix must -// precede any RFB byte so the agent's verifyAgentToken can run first. -// Returns nil once a stream is established; the caller is responsible -// for sending an RFB-level rejection on error so the client sees a -// reason instead of a bare timeout. -func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken string, viewOnly bool) error { +// proxyToAgent dials the per-session agent's Unix socket, validates the +// peer's kernel-asserted uid (so the daemon never hands its per-spawn +// token to an impostor that won the listen race), writes the raw token +// bytes plus a single view-only flag byte, then copies bytes both ways +// until either side closes. The token + flag prefix must precede any RFB +// byte so the agent's verifyAgentToken can run first. Returns nil once a +// stream is established; the caller is responsible for sending an +// RFB-level rejection on error so the client sees a reason instead of a +// bare timeout. authedLog receives one audit line per dispatched +// preamble so an operator can correlate daemon→agent traffic with the +// remote session that triggered it. +func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken string, peerUID uint32, viewOnly bool, authedLog *log.Entry) error { tokenBytes, err := hex.DecodeString(authToken) if err != nil || len(tokenBytes) != agentTokenLen { return fmt.Errorf("invalid auth token (len=%d): %w", len(tokenBytes), err) @@ -152,6 +163,11 @@ func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken st return fmt.Errorf("dial agent at %s: %w", socketPath, err) } + if err := validateAgentPeer(agentConn, peerUID); err != nil { + _ = agentConn.Close() + return fmt.Errorf("agent peer validation failed: %w", err) + } + preamble := make([]byte, len(tokenBytes)+1) copy(preamble, tokenBytes) if viewOnly { @@ -162,6 +178,17 @@ func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken st return fmt.Errorf("send auth preamble to agent: %w", err) } + // Audit: one line per successfully-dispatched daemon→agent preamble. + // Token printed as its first 8 hex chars (enough to correlate, not + // enough to use). Kept at Info so the default deployment captures it. + tokenFp := authToken + if len(tokenFp) > 8 { + tokenFp = tokenFp[:8] + } + if authedLog != nil { + authedLog.Infof("VNC IPC: dispatched preamble to agent socket=%s peer_uid=%d view_only=%v token_fp=%s", socketPath, peerUID, viewOnly, tokenFp) + } + defer client.Close() defer agentConn.Close() log.Debugf("proxy connected to agent, starting bidirectional copy") diff --git a/client/vnc/server/agent_peercred_darwin.go b/client/vnc/server/agent_peercred_darwin.go new file mode 100644 index 00000000000..d487687a9ca --- /dev/null +++ b/client/vnc/server/agent_peercred_darwin.go @@ -0,0 +1,46 @@ +//go:build darwin && !ios + +package server + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// validateAgentPeer enforces that the peer behind the just-connected Unix +// socket is the agent we expect it to be: a process running under +// expectedUID, with the right effective uid stamped by the kernel on the +// socket. Refuses (with a non-nil error) if anything else is listening on +// the path (an unrelated local process that won the listen race or +// squatted the path before us). Defends against the daemon shipping its +// per-spawn auth token to a process that isn't the spawned agent. +func validateAgentPeer(conn net.Conn, expectedUID uint32) error { + uconn, ok := conn.(*net.UnixConn) + if !ok { + return fmt.Errorf("peer cred: expected *net.UnixConn, got %T", conn) + } + raw, err := uconn.SyscallConn() + if err != nil { + return fmt.Errorf("peer cred: syscall conn: %w", err) + } + var cred *unix.Xucred + var inner error + ctlErr := raw.Control(func(fd uintptr) { + cred, inner = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }) + if ctlErr != nil { + return fmt.Errorf("peer cred: control: %w", ctlErr) + } + if inner != nil { + return fmt.Errorf("peer cred: getsockopt LOCAL_PEERCRED: %w", inner) + } + if cred == nil { + return fmt.Errorf("peer cred: nil xucred") + } + if cred.Uid != expectedUID { + return fmt.Errorf("peer cred: agent uid %d does not match expected %d", cred.Uid, expectedUID) + } + return nil +} diff --git a/client/vnc/server/agent_peercred_darwin_test.go b/client/vnc/server/agent_peercred_darwin_test.go new file mode 100644 index 00000000000..7e559dc5179 --- /dev/null +++ b/client/vnc/server/agent_peercred_darwin_test.go @@ -0,0 +1,115 @@ +//go:build darwin && !ios + +package server + +import ( + "net" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// TestValidateAgentPeerAcceptsOwnUID confirms the happy path: a Unix +// socket whose peer is the current process must validate when the +// expected uid matches the process's own. Both sides of a unix-socket +// pair share the same kernel cred, so this exercises the real getsockopt +// LOCAL_PEERCRED path. +func TestValidateAgentPeerAcceptsOwnUID(t *testing.T) { + dir := t.TempDir() + sockPath := filepath.Join(dir, "test.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + c, err := ln.Accept() + if err == nil { + _ = c.Close() + } + }() + c, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + if err := validateAgentPeer(c, uint32(os.Getuid())); err != nil { + t.Fatalf("validateAgentPeer rejected own uid: %v", err) + } + wg.Wait() +} + +// TestValidateAgentPeerRejectsWrongUID ensures the validator fails when +// the expected uid differs from the kernel-reported peer uid. This is +// the path that catches a hostile process that won the listen race. +func TestValidateAgentPeerRejectsWrongUID(t *testing.T) { + dir := t.TempDir() + sockPath := filepath.Join(dir, "test.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + c, err := ln.Accept() + if err == nil { + _ = c.Close() + } + }() + c, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + // Pick a uid the test process certainly isn't running as. + wrongUID := uint32(os.Getuid()) + 1 + err = validateAgentPeer(c, wrongUID) + if err == nil { + t.Fatal("expected mismatch error, got nil") + } + if !strings.Contains(err.Error(), "does not match expected") { + t.Fatalf("error should mention uid mismatch, got: %v", err) + } + wg.Wait() +} + +// TestValidateAgentPeerRejectsNonUnix protects against being handed a +// non-Unix-socket connection (the validator can't enforce anything on +// e.g. a *net.TCPConn so it must refuse rather than silently pass). +func TestValidateAgentPeerRejectsNonUnix(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen tcp: %v", err) + } + defer ln.Close() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + c, err := ln.Accept() + if err == nil { + _ = c.Close() + } + }() + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial tcp: %v", err) + } + defer c.Close() + if err := validateAgentPeer(c, 0); err == nil { + t.Fatal("expected refusal on non-unix conn, got nil") + } + wg.Wait() +} diff --git a/client/vnc/server/agent_peercred_windows.go b/client/vnc/server/agent_peercred_windows.go new file mode 100644 index 00000000000..6f2d3bc5c92 --- /dev/null +++ b/client/vnc/server/agent_peercred_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package server + +import ( + "net" +) + +// validateAgentPeer is a best-effort no-op on Windows: AF_UNIX sockets on +// Windows do not expose SO_PEERCRED equivalents, and both the daemon and +// the spawned agent run as SYSTEM in distinct sessions. The remaining +// trust comes from the location of the socket file (under +// C:\Windows\Temp, writable only by SYSTEM/Administrators) and from the +// per-spawn auth token preamble that follows this call. Documented as a +// known gap; a future hardening pass could interrogate the connected +// pipe's PID via process-token APIs. +func validateAgentPeer(_ net.Conn, _ uint32) error { + return nil +} diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 32c80383474..64192d46973 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -421,18 +421,20 @@ func createKillOnCloseJob() (windows.Handle, error) { return job, nil } -// Resolve returns the current agent socket path and token. When no -// agent is spawned yet (initial boot, between session switches, or -// permanently disabled when SE_TCB_NAME is missing) it surfaces a -// distinct error so the daemon can reject the connection with a -// meaningful message instead of timing out the proxy dial. -func (m *sessionManager) Resolve(_ context.Context) (string, string, error) { +// Resolve returns the current agent socket path, shared token, and the +// uid the agent runs under (0 on Windows since the agent runs as +// SYSTEM in the interactive session; validateAgentPeer is a no-op +// there). When no agent is spawned yet (initial boot, between session +// switches, or permanently disabled when SE_TCB_NAME is missing) it +// surfaces a distinct error so the daemon can reject the connection +// with a meaningful message instead of timing out the proxy dial. +func (m *sessionManager) Resolve(_ context.Context) (string, string, uint32, error) { m.mu.Lock() defer m.mu.Unlock() if m.socketPath == "" { - return "", "", errAgentNotReady + return "", "", 0, errAgentNotReady } - return m.socketPath, m.authToken, nil + return m.socketPath, m.authToken, 0, nil } var errAgentNotReady = errors.New("VNC agent not running yet") diff --git a/client/vnc/server/copyrect.go b/client/vnc/server/copyrect.go index 2e0fb56fd97..ce75e41fe0b 100644 --- a/client/vnc/server/copyrect.go +++ b/client/vnc/server/copyrect.go @@ -156,9 +156,21 @@ func (d *copyRectDetector) findTileMatch(cur *image.RGBA, dstX, dstY int) (int, if pos[0] == dstX && pos[1] == dstY { return 0, 0, false } + // Reject source coords that fall outside the current framebuffer + // (frame may have shrunk since the source position was recorded). A + // CopyRect with an out-of-range source would have the client copy + // from undefined pixels, so drop the match and let the encoder send + // the rect normally. + if pos[0] < 0 || pos[1] < 0 || pos[0]+ts > cur.Rect.Dx() || pos[1]+ts > cur.Rect.Dy() { + return 0, 0, false + } // Reject stale entries: the position the map points at must still // carry the same hash according to our per-tile array. - if d.tileHash[(pos[1]/ts)*d.cols+(pos[0]/ts)] != sum { + hashIdx := (pos[1]/ts)*d.cols + pos[0]/ts + if hashIdx < 0 || hashIdx >= len(d.tileHash) { + return 0, 0, false + } + if d.tileHash[hashIdx] != sum { return 0, 0, false } return pos[0], pos[1], true diff --git a/client/vnc/server/cursor_windows.go b/client/vnc/server/cursor_windows.go index b9a92834f26..be65a4b1a7d 100644 --- a/client/vnc/server/cursor_windows.go +++ b/client/vnc/server/cursor_windows.go @@ -227,6 +227,9 @@ func dibCopy(hbm windows.Handle, w, h int32) ([]byte, error) { bih.BiBitCount = 32 bih.BiCompression = biRgb + if w <= 0 || h <= 0 || w > maxCursorDim || h > maxCursorDim { + return nil, fmt.Errorf("dibCopy: cursor dims %dx%d out of range (max %d)", w, h, maxCursorDim) + } buf := make([]byte, int(w)*int(h)*4) r, _, err := procGetDIBits.Call( hdcMem, diff --git a/client/vnc/server/handshake.go b/client/vnc/server/handshake.go index 50231cff8ab..ddbc475fca3 100644 --- a/client/vnc/server/handshake.go +++ b/client/vnc/server/handshake.go @@ -33,6 +33,29 @@ const ( // it requires bumping vncIdentityMagic so old clients fail closed. var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) +// vncNoisePrologueMagic prefixes the Noise prologue. Both sides mix the +// magic + mode byte + length-prefixed username into the handshake hash +// before any message is sent. Catches a client that lies about its +// mode/username in the cleartext header prefix: the cleartext header +// would say one thing and the Noise hash would expect another, so the +// responder's AEAD MAC over the handshake state fails to verify and the +// handshake collapses. Bumping the magic forces old clients to fail +// closed because their prologue stops matching ours. +var vncNoisePrologueMagic = []byte("NetBird/VNC/Noise/v1\x00") + +// BuildVNCNoisePrologue returns the deterministic byte sequence both +// sides feed to noise.Config.Prologue for a VNC handshake. Exported so +// the WASM proxy client computes the exact same bytes; any divergence +// makes the handshake fail. +func BuildVNCNoisePrologue(mode byte, username string) []byte { + out := make([]byte, 0, len(vncNoisePrologueMagic)+1+2+len(username)) + out = append(out, vncNoisePrologueMagic...) + out = append(out, mode) + out = append(out, byte(len(username)>>8), byte(len(username))) + out = append(out, []byte(username)...) + return out +} + func (s *Server) authenticateSession(header *connectionHeader) (string, error) { if !header.identityVerified { return "", fmt.Errorf("identity proof missing") @@ -97,7 +120,7 @@ func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) } br := bufio.NewReader(conn) - clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br) + clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br, mode, username) if err != nil { return nil, err } @@ -129,8 +152,10 @@ func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) // maybeRunNoiseHandshake performs the responder side of a Noise_IK // handshake when the client sends the v3 magic. Returns the client static // public key learned from the handshake. Any handshake failure is fatal -// (fail closed). -func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte, bool, error) { +// (fail closed). headerMode and headerUsername are mixed into the Noise +// prologue so the client cannot lie in the cleartext header prefix +// without making its own AEAD MAC verify-fail on the responder side. +func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader, headerMode byte, headerUsername string) ([]byte, bool, error) { peek, _ := br.Peek(len(vncIdentityMagic)) if !bytes.Equal(peek, vncIdentityMagic) { return nil, false, nil @@ -145,9 +170,16 @@ func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte } // Agents on loopback authenticate via the agent token, not this - // handshake. Consume the replayed bytes and skip the response. + // handshake: the daemon already ran Noise on the public side and + // is now proxying the replayed bytes through to us. Consume the + // bytes and report identityVerified=false: the agent's own + // authorizeSession short-circuits on disableAuth and never reaches + // authenticateSession, so this return value has no effect on the + // agent's accept path, but a future caller that forgets the + // short-circuit will see the truthful "no Noise identity proved + // here" rather than a stale true. if s.disableAuth { - return nil, true, nil + return nil, false, nil } if len(s.identityKey) != 32 || len(s.identityPublic) != 32 { @@ -157,6 +189,7 @@ func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte CipherSuite: vncNoiseSuite, Pattern: noise.HandshakeIK, Initiator: false, + Prologue: BuildVNCNoisePrologue(headerMode, headerUsername), StaticKeypair: noise.DHKey{Private: s.identityKey, Public: s.identityPublic}, }) if err != nil { diff --git a/client/vnc/server/noise_auth_test.go b/client/vnc/server/noise_auth_test.go index 08eae08393b..9ec30d4424e 100644 --- a/client/vnc/server/noise_auth_test.go +++ b/client/vnc/server/noise_auth_test.go @@ -65,11 +65,22 @@ func registerSessionKey(t *testing.T, srv *Server, userID string) noise.DHKey { return kp } -// writeHeaderPrefix writes the mode + zero-length-username prefix that +// writeHeaderPrefix writes the mode + (optional) username prefix that // precedes the optional Noise handshake in the NetBird VNC header. func writeHeaderPrefix(t *testing.T, conn net.Conn, mode byte) { t.Helper() - prefix := []byte{mode, 0, 0} + writeHeaderPrefixWithUser(t, conn, mode, "") +} + +// writeHeaderPrefixWithUser is the username-aware variant used by tests +// that need to verify the Noise prologue binds to the cleartext header. +func writeHeaderPrefixWithUser(t *testing.T, conn net.Conn, mode byte, username string) { + t.Helper() + if len(username) > 0xFFFF { + t.Fatalf("test username too long: %d", len(username)) + } + prefix := []byte{mode, byte(len(username) >> 8), byte(len(username))} + prefix = append(prefix, []byte(username)...) _, err := conn.Write(prefix) require.NoError(t, err) } @@ -85,14 +96,22 @@ func writeHeaderTail(t *testing.T, conn net.Conn) { // performInitiator drives the initiator side of Noise_IK against the // server's identity public key, returns the resulting state. The Noise -// msg2 produced by the server is read and consumed. +// msg2 produced by the server is read and consumed. headerMode and +// headerUsername are mixed into the prologue and MUST match what the +// caller already wrote in the cleartext header prefix. func performInitiator(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte) { t.Helper() + performInitiatorWithHeader(t, conn, clientKey, serverPub, ModeAttach, "") +} + +func performInitiatorWithHeader(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte, headerMode byte, headerUsername string) { + t.Helper() state, err := noise.NewHandshakeState(noise.Config{ CipherSuite: vncNoiseSuite, Pattern: noise.HandshakeIK, Initiator: true, + Prologue: BuildVNCNoisePrologue(headerMode, headerUsername), StaticKeypair: clientKey, PeerStatic: serverPub, }) @@ -414,18 +433,15 @@ func TestNoise_SessionMode_OSUserCheckRunsAfterHandshake(t *testing.T) { }, }) - // Request session for "bob" — Noise succeeds, OS-user check denies. + // Request session for "bob": Noise succeeds, OS-user check denies. conn, err := net.Dial("tcp", addr.String()) require.NoError(t, err) defer conn.Close() - bob := []byte("bob") - prefix := []byte{ModeSession, 0, byte(len(bob))} - prefix = append(prefix, bob...) - _, err = conn.Write(prefix) - require.NoError(t, err) + bob := "bob" + writeHeaderPrefixWithUser(t, conn, ModeSession, bob) - performInitiator(t, conn, clientKey, serverPub) + performInitiatorWithHeader(t, conn, clientKey, serverPub, ModeSession, bob) writeHeaderTail(t, conn) reason := readRFBFailure(t, conn) diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index f889676420e..04d9c749028 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -488,14 +488,27 @@ func tileChanged(prev, cur *image.RGBA, x, y, w, h int) bool { // tileIsUniform reports whether every pixel in the given rectangle of img is // the same RGBA value, and returns that pixel packed as 0xRRGGBBAA when so. // Uses uint32 comparisons across rows; returns early on the first mismatch. +// Returns (0, false) on any out-of-range rectangle so an unsafe pointer +// deref can never reach past img.Pix even if a capturer reports stale +// dimensions or a resize race produces inconsistent state. func tileIsUniform(img *image.RGBA, x, y, w, h int) (uint32, bool) { - if w <= 0 || h <= 0 { + if w <= 0 || h <= 0 || x < 0 || y < 0 { + return 0, false + } + bounds := img.Rect + if x+w > bounds.Dx() || y+h > bounds.Dy() { return 0, false } stride := img.Stride base := y*stride + x*4 - first := *(*uint32)(unsafe.Pointer(&img.Pix[base])) rowBytes := w * 4 + // Final row's last pixel must be inside Pix; guard against any caller + // that managed to slip past the bounds check above (e.g. negative + // stride from a forged image). + if base < 0 || base+(h-1)*stride+rowBytes > len(img.Pix) { + return 0, false + } + first := *(*uint32)(unsafe.Pointer(&img.Pix[base])) for row := 0; row < h; row++ { p := base + row*stride for col := 0; col < rowBytes; col += 4 { @@ -621,10 +634,18 @@ func writeTightRectHeader(buf []byte, x, y, w, h int) { // appendTightLength encodes a Tight compact length prefix (1, 2, or 3 bytes // LE-ish, top bit of each byte signals continuation). Lengths exceeding // tightMaxLength would silently truncate the high byte; callers must clamp -// or fall back before reaching here. +// or fall back before reaching here. Out-of-range values are clamped and +// logged instead of panicking so a malformed encode can't tear the whole +// server down: callers already check the cap, so this branch is just a +// defence-in-depth backstop. func appendTightLength(buf []byte, n int) []byte { - if n < 0 || n > tightMaxLength { - panic(fmt.Sprintf("tight length out of range: %d", n)) + if n < 0 { + log.Warnf("tight length negative (%d); clamping to 0", n) + n = 0 + } + if n > tightMaxLength { + log.Warnf("tight length %d exceeds cap %d; clamping (caller should have fallen back)", n, tightMaxLength) + n = tightMaxLength } b0 := byte(n & 0x7f) if n <= 0x7f { @@ -765,7 +786,19 @@ func tightQualityFor(pixels int) int { // per-rect Tight encoding stays alloc-free. Cheap O(maxColors) per call. func sampledColorCountInto(seen map[uint32]struct{}, img *image.RGBA, x, y, w, h, maxColors int) int { clear(seen) + if w <= 0 || h <= 0 || x < 0 || y < 0 { + return 0 + } + bounds := img.Rect + if x+w > bounds.Dx() || y+h > bounds.Dy() { + return 0 + } stride := img.Stride + // Defensive: refuse to dereference past the buffer end if stride math + // somehow disagrees with bounds (e.g. caller passed a SubImage). + if (y+h-1)*stride+(x+w)*4 > len(img.Pix) { + return 0 + } step := max((w*h)/(maxColors*4), 1) var idx int for row := 0; row < h; row++ { diff --git a/client/vnc/server/security_hardening_test.go b/client/vnc/server/security_hardening_test.go new file mode 100644 index 00000000000..6c60c21f714 --- /dev/null +++ b/client/vnc/server/security_hardening_test.go @@ -0,0 +1,374 @@ +//go:build !js && !ios && !android + +package server + +import ( + "image" + "net" + "strings" + "testing" + "time" + + "github.com/flynn/noise" + "github.com/stretchr/testify/require" +) + +// TestTileIsUniformRejectsOutOfRange covers the bounds-check guard added to +// tileIsUniform. Each case below would, before the guard, have produced an +// unsafe.Pointer dereference past the end of img.Pix; the function must now +// return (0,false) and not panic. +func TestTileIsUniformRejectsOutOfRange(t *testing.T) { + img := makeUniformImage(64, 64, 0x11, 0x22, 0x33) + cases := []struct { + name string + x, y, w, h int + }{ + {"negative_x", -1, 0, 8, 8}, + {"negative_y", 0, -1, 8, 8}, + {"x_past_right_edge", 60, 0, 8, 8}, + {"y_past_bottom_edge", 0, 60, 8, 8}, + {"w_overflows_into_oob", 0, 0, 65, 8}, + {"h_overflows_into_oob", 0, 0, 8, 65}, + {"zero_width", 0, 0, 0, 8}, + {"zero_height", 0, 0, 8, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("tileIsUniform panicked: %v", r) + } + }() + pixel, uniform := tileIsUniform(img, tc.x, tc.y, tc.w, tc.h) + if uniform { + t.Fatalf("expected uniform=false on out-of-range, got pixel=%#x", pixel) + } + }) + } +} + +func TestTileIsUniformInRangeStillWorks(t *testing.T) { + img := makeUniformImage(64, 64, 0x12, 0x34, 0x56) + pixel, uniform := tileIsUniform(img, 8, 8, 16, 16) + if !uniform { + t.Fatal("expected uniform=true for uniformly-painted rect") + } + // Pixel is BGRA-shifted internally; just confirm it is non-zero so we + // know the deref ran. + if pixel == 0 { + t.Fatal("expected non-zero packed pixel") + } +} + +// TestSampledColorCountIntoRejectsOutOfRange mirrors the bounds-check guard +// added to sampledColorCountInto: any out-of-range rect must yield 0 with +// no panic and no map mutation that would propagate stale colors. +func TestSampledColorCountIntoRejectsOutOfRange(t *testing.T) { + img := makeUniformImage(64, 64, 0x11, 0x22, 0x33) + seen := make(map[uint32]struct{}, 16) + cases := []struct { + name string + x, y, w, h int + }{ + {"negative_x", -1, 0, 8, 8}, + {"negative_y", 0, -1, 8, 8}, + {"x_past_right_edge", 60, 0, 8, 8}, + {"y_past_bottom_edge", 0, 60, 8, 8}, + {"w_overflows_into_oob", 0, 0, 65, 8}, + {"h_overflows_into_oob", 0, 0, 8, 65}, + {"zero_dims", 0, 0, 0, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("sampledColorCountInto panicked: %v", r) + } + }() + n := sampledColorCountInto(seen, img, tc.x, tc.y, tc.w, tc.h, 256) + if n != 0 { + t.Fatalf("expected 0 colors on out-of-range rect, got %d", n) + } + }) + } +} + +// TestAppendTightLengthClampsInsteadOfPanicking ensures the function no +// longer panics on out-of-range input: a panic would tear down the entire +// VNC server when the encoder hits an unexpected length. +func TestAppendTightLengthClampsInsteadOfPanicking(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("appendTightLength panicked: %v", r) + } + }() + _ = appendTightLength(nil, -1) + _ = appendTightLength(nil, tightMaxLength+1) + _ = appendTightLength(nil, 1<<30) +} + +// TestEncodeCursorPseudoRectCapsDimensions ensures the cursor encoder +// refuses unreasonably large sprites: a bad platform-API response with +// w*h*4 that overflows int would otherwise produce an undersized buf and +// a heap overflow on the subsequent copy. +func TestEncodeCursorPseudoRectCapsDimensions(t *testing.T) { + t.Run("oversized_rejected", func(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, maxCursorDim+1, 1)) + if buf := encodeCursorPseudoRect(img, 0, 0); buf != nil { + t.Fatalf("expected nil for oversized cursor, got %d bytes", len(buf)) + } + }) + t.Run("nil_rejected", func(t *testing.T) { + if buf := encodeCursorPseudoRect(nil, 0, 0); buf != nil { + t.Fatal("expected nil for nil image") + } + }) + t.Run("zero_dims_rejected", func(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 0, 0)) + if buf := encodeCursorPseudoRect(img, 0, 0); buf != nil { + t.Fatal("expected nil for zero-dim image") + } + }) + t.Run("small_cursor_still_encodes", func(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 16, 16)) + // Paint a quasi-opaque sprite so the mask path runs. + for i := range img.Pix { + img.Pix[i] = 0x80 + } + buf := encodeCursorPseudoRect(img, 1, 2) + if buf == nil { + t.Fatal("expected encoded cursor, got nil") + } + // 12-byte rect header + w*h*4 pixels + ((w+7)/8)*h mask bytes. + want := 12 + 16*16*4 + ((16+7)/8)*16 + if len(buf) != want { + t.Fatalf("cursor rect length: got %d want %d", len(buf), want) + } + }) +} + +// TestEncodeCursorPseudoRectAtMaxDim sanity-checks the boundary: the cap +// must allow exactly maxCursorDim×maxCursorDim through. +func TestEncodeCursorPseudoRectAtMaxDim(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, maxCursorDim, maxCursorDim)) + if buf := encodeCursorPseudoRect(img, 0, 0); buf == nil { + t.Fatal("expected non-nil for max-dim cursor (boundary)") + } +} + +// TestCopyRectFindTileRejectsOutOfRangeSrc covers the additional source- +// position guard added to findTileMatch. A scenario where the source +// position recorded in prevTiles is now outside the (possibly shrunken) +// current framebuffer must produce no match: otherwise the encoder would +// emit a CopyRect telling the client to copy from undefined pixels. +func TestCopyRectFindTileRejectsOutOfRangeSrc(t *testing.T) { + const ts = 64 + const w, h = 128, 128 + cur := image.NewRGBA(image.Rect(0, 0, w, h)) + fillTile(cur, 0, 0, ts, 0x11, 0x22, 0x33) + + d := newCopyRectDetector(ts) + // Pre-populate prevTiles with a stale source position that falls + // outside the current framebuffer; this mirrors what would happen + // after a resize. We compute the same hash the detector would use + // for the tile at (0,0) of cur and bind that hash to an out-of-range + // source. + hash := d.hashTile(cur, 0, 0) + d.cols = w / ts + d.tileHash = make([]uint64, (w/ts)*(h/ts)) + d.prevTiles = map[uint64][2]int{ + hash: {w + ts, h + ts}, // out of range + } + + sx, sy, ok := d.findTileMatch(cur, 0, 0) + if ok { + t.Fatalf("expected no match for out-of-range source, got (%d,%d)", sx, sy) + } +} + +// TestBuildVNCNoisePrologueDeterministic locks in the format both sides +// MUST agree on. Drift here breaks every VNC handshake silently (with +// just an "authentication failed" error), so any future refactor that +// changes this output needs to bump the prologue magic and ship a +// migration. +func TestBuildVNCNoisePrologueDeterministic(t *testing.T) { + a := BuildVNCNoisePrologue(ModeAttach, "") + b := BuildVNCNoisePrologue(ModeAttach, "") + if string(a) != string(b) { + t.Fatalf("non-deterministic prologue: %x vs %x", a, b) + } + + // Different mode must produce a distinct prologue. + if string(a) == string(BuildVNCNoisePrologue(ModeSession, "")) { + t.Fatal("mode change must change prologue") + } + // Different username must produce a distinct prologue. + if string(a) == string(BuildVNCNoisePrologue(ModeAttach, "alice")) { + t.Fatal("username change must change prologue") + } + // Magic prefix must be present so a missing/short prologue (e.g. + // an old client that wasn't recompiled) fails closed. + if !strings.HasPrefix(string(a), "NetBird/VNC/Noise/v1") { + t.Fatalf("prologue missing magic prefix: %q", a) + } +} + +// TestNoise_ClientLiesAboutMode_HandshakeFails proves the prologue +// binding catches a client that writes one mode in the cleartext header +// prefix and then tries to mint a Noise handshake claiming a different +// mode. Without binding, an attacker could declare mode=attach (loose +// OS-user check) while the Noise hash committed to mode=session, +// pretending to be a session user when the server's policy gate ran on +// the attach path. +func TestNoise_ClientLiesAboutMode_HandshakeFails(t *testing.T) { + addr, srv, serverPub := noiseTestServer(t) + clientKey := registerSessionKey(t, srv, "alice@example") + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + // Cleartext header says session, but Noise prologue commits to + // attach. Server reads the cleartext, computes a prologue with + // session, and the AEAD MAC over the handshake state fails. + writeHeaderPrefixWithUser(t, conn, ModeSession, "alice") + + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: true, + Prologue: BuildVNCNoisePrologue(ModeAttach, "alice"), + StaticKeypair: clientKey, + PeerStatic: serverPub, + }) + require.NoError(t, err) + msg1, _, _, err := state.WriteMessage(nil, nil) + require.NoError(t, err) + _, err = conn.Write(append([]byte("NBV3"), msg1...)) + require.NoError(t, err) + + // Server must reject the connection: either by failing the read + // of msg2 (the connection is closed) or by sending an RFB failure. + require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second))) + msg2 := make([]byte, noiseResponderMsgLen) + if _, err := readFullOrEOF(conn, msg2); err == nil { + // If the server did write something, it must not be a valid + // Noise msg2: ReadMessage must fail. + _, _, _, derr := state.ReadMessage(nil, msg2) + if derr == nil { + t.Fatal("expected Noise read to fail when client lies about mode") + } + } +} + +// TestNoise_ClientLiesAboutUsername_HandshakeFails mirrors the mode +// check above for the username field, which is the other piece of +// cleartext header the prologue binds to. +func TestNoise_ClientLiesAboutUsername_HandshakeFails(t *testing.T) { + addr, srv, serverPub := noiseTestServer(t) + clientKey := registerSessionKey(t, srv, "alice@example") + + conn, err := net.Dial("tcp", addr.String()) + require.NoError(t, err) + defer conn.Close() + + writeHeaderPrefixWithUser(t, conn, ModeSession, "alice") + + state, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: vncNoiseSuite, + Pattern: noise.HandshakeIK, + Initiator: true, + Prologue: BuildVNCNoisePrologue(ModeSession, "bob"), // lies + StaticKeypair: clientKey, + PeerStatic: serverPub, + }) + require.NoError(t, err) + msg1, _, _, err := state.WriteMessage(nil, nil) + require.NoError(t, err) + _, err = conn.Write(append([]byte("NBV3"), msg1...)) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second))) + msg2 := make([]byte, noiseResponderMsgLen) + if _, err := readFullOrEOF(conn, msg2); err == nil { + _, _, _, derr := state.ReadMessage(nil, msg2) + if derr == nil { + t.Fatal("expected Noise read to fail when client lies about username") + } + } +} + +// readFullOrEOF returns nil if buf was fully populated, or an error if +// the connection closed first. Used by the binding tests to tolerate +// the server's two valid failure modes (close vs RFB failure). +func readFullOrEOF(conn net.Conn, buf []byte) (int, error) { + n, err := conn.Read(buf) + for n < len(buf) && err == nil { + var k int + k, err = conn.Read(buf[n:]) + n += k + } + return n, err +} + +// TestRegisterConnAuth_RaceWithRevocation covers the TOCTOU race the +// fix in registerConnAuth closes. Without the re-check, a concurrent +// UpdateVNCAuth that removes the client's pubkey AFTER authorizeSession +// runs but BEFORE registerConnAuth inserts into connAuth would leave an +// unauthorized session running until the next policy push. +func TestRegisterConnAuth_RaceWithRevocation(t *testing.T) { + _, srv, _ := noiseTestServer(t) + clientKey := registerSessionKey(t, srv, "alice@example") + + header := &connectionHeader{ + identityVerified: true, + clientStatic: clientKey.Public, + mode: ModeAttach, + } + + // Authoritative simulation of the race: first registerConnAuth + // succeeds (caller is in policy), then policy is updated to remove + // the caller's pubkey, then a fresh registration attempt must be + // refused even though the original authorizeSession path already + // said ok=true. + conn1, conn2 := net.Pipe() + defer conn1.Close() + defer conn2.Close() + require.NoError(t, srv.registerConnAuth(conn1, header)) + + // Revoke: empty pubkey list, nobody is authorized anymore. + srv.UpdateVNCAuth(nil) + + conn3, conn4 := net.Pipe() + defer conn3.Close() + defer conn4.Close() + err := srv.registerConnAuth(conn3, header) + if err == nil { + t.Fatal("expected registerConnAuth to refuse after revocation, got nil") + } + if !strings.Contains(err.Error(), "authorization revoked") { + t.Fatalf("unexpected error from post-revocation register: %v", err) + } +} + +// TestEncoderPanicRecovery ensures processFBRequestSafe catches a panic +// from the encode path and surfaces it as an error rather than tearing +// down every session. +func TestEncoderPanicRecovery(t *testing.T) { + // A session whose encMu is nil-safe-enough that processFBRequest can + // be called and induce a deterministic panic at one of its earliest + // dereferences. We only need the recover wrapper to engage. + s := &session{} + defer func() { + if r := recover(); r != nil { + t.Fatalf("processFBRequestSafe leaked a panic: %v", r) + } + }() + err := s.processFBRequestSafe(fbRequest{}) + if err == nil { + t.Fatal("expected an error from the recovered panic, got nil") + } + if !strings.Contains(err.Error(), "encoder panic") { + t.Fatalf("error should mention encoder panic, got: %v", err) + } +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 0f137d3c576..4c31aae2165 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -478,17 +478,30 @@ func sourceIPString(addr net.Addr) string { // registerConnAuth records the verified Noise_IK identity for a live // connection so UpdateVNCAuth can later revoke it if policy changes. // No-op when auth is disabled (e.g. agent-mode loopback connections). -func (s *Server) registerConnAuth(c net.Conn, header *connectionHeader) { +// +// The original authorization check in authorizeSession and this +// registration are not atomic, so a concurrent UpdateVNCAuth can revoke +// the client's pubkey in between (revokeUnauthorizedSessions iterates +// connAuth and would miss this connection because it isn't registered +// yet). To close that window we re-run authenticateSession here under +// the same sessionsMu that revokeUnauthorizedSessions holds; if the +// caller's pubkey is no longer authorized at registration time, we +// refuse the registration and the caller tears the connection down. +func (s *Server) registerConnAuth(c net.Conn, header *connectionHeader) error { if s.disableAuth || header == nil || len(header.clientStatic) != 32 { - return + return nil } s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + if _, err := s.authenticateSession(header); err != nil { + return fmt.Errorf("authorization revoked before session registration: %w", err) + } s.connAuth[c] = connAuthInfo{ clientStatic: append([]byte(nil), header.clientStatic...), mode: header.mode, username: header.username, } - s.sessionsMu.Unlock() + return nil } // tryAcquireConnSlot returns true when a connection slot was successfully @@ -798,7 +811,11 @@ func (s *Server) handleConnection(conn net.Conn) { connLog.Info("VNC connection rejected: auth failed") return } - s.registerConnAuth(conn, header) + if err := s.registerConnAuth(conn, header); err != nil { + rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error())) + connLog.Warnf("VNC connection rejected: %v", err) + return + } decision, err := s.gateApproval(conn, header) if err != nil { @@ -818,20 +835,16 @@ func (s *Server) handleConnection(conn net.Conn) { } defer sessionCleanup() - sessionID := s.addSession(ActiveSessionInfo{ - RemoteAddress: conn.RemoteAddr().String(), - Mode: modeString(header.mode), - Username: header.username, - UserID: sessionUserID, - }, conn) - defer s.removeSession(sessionID) - if err := s.validateCapturer(capturer); err != nil { rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("screen capturer: %v", err))) connLog.Warnf("VNC connection rejected: capturer not ready: %v", err) return } + // Validate framebuffer dimensions BEFORE registering the active + // session: keeps a misbehaving capturer from briefly showing up in + // ActiveSessions output and ensures the rest of the pipeline only + // ever runs against an in-range frame. w, h := capturer.Width(), capturer.Height() if w <= 0 || h <= 0 || w > maxFramebufferDim || h > maxFramebufferDim { rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("framebuffer dimensions out of range: %dx%d", w, h))) @@ -839,6 +852,14 @@ func (s *Server) handleConnection(conn net.Conn) { return } + sessionID := s.addSession(ActiveSessionInfo{ + RemoteAddress: conn.RemoteAddr().String(), + Mode: modeString(header.mode), + Username: header.username, + UserID: sessionUserID, + }, conn) + defer s.removeSession(sessionID) + conn = newMetricsConn(conn, s.sessionRecorder) sess := &session{ conn: conn, diff --git a/client/vnc/server/session_cursor.go b/client/vnc/server/session_cursor.go index 2ab62ea2732..a58ebb43784 100644 --- a/client/vnc/server/session_cursor.go +++ b/client/vnc/server/session_cursor.go @@ -37,21 +37,59 @@ func (s *session) pendingCursorRect() []byte { return nil } buf := encodeCursorPseudoRect(img, hotX, hotY) + if buf == nil { + return nil + } + // Re-check the serial under the write lock so a concurrent update + // from another goroutine can't be silently overwritten with a stale + // value: if someone advanced it past `serial` while we were encoding, + // keep their value and drop this rect. s.encMu.Lock() + if serial == s.lastCursorSerial { + s.encMu.Unlock() + return nil + } + if uint64(serial-s.lastCursorSerial) > 1<<63 { + // `serial` is older than the current value (wraparound-aware + // comparison). Drop it. + s.encMu.Unlock() + return nil + } s.lastCursorSerial = serial s.encMu.Unlock() return buf } +// maxCursorDim caps the cursor sprite size we'll encode. Real platform +// cursors are tiny (<=256×256 on every supported OS); a value past this +// almost certainly indicates a corrupted platform-API response, and +// blindly multiplying it into a buffer size would overflow int and produce +// an undersized allocation that the encode loop would then walk past. +const maxCursorDim = 256 + // encodeCursorPseudoRect packs the cursor sprite into a Cursor pseudo // rectangle (RFB 7.7.4, pseudo-encoding -239). Layout: 12-byte rect header // followed by w*h*4 BGRX pixel bytes and a 1-bit mask of (w+7)/8 bytes per -// row, MSB-first, with each row independently padded. +// row, MSB-first, with each row independently padded. Returns nil when +// the source image's dimensions are non-positive or exceed maxCursorDim; +// callers treat nil as "skip the cursor rect this frame." func encodeCursorPseudoRect(img *image.RGBA, hotX, hotY int) []byte { + if img == nil { + return nil + } w, h := img.Rect.Dx(), img.Rect.Dy() + if w <= 0 || h <= 0 || w > maxCursorDim || h > maxCursorDim { + return nil + } pixelBytes := w * h * 4 maskStride := (w + 7) / 8 maskBytes := maskStride * h + // Defensive: ensure the source image is actually big enough for the + // access pattern below. A SubImage that misreports its dx/dy would + // otherwise be read past the end. + if (h-1)*img.Stride+w*4 > len(img.Pix) { + return nil + } buf := make([]byte, 12+pixelBytes+maskBytes) binary.BigEndian.PutUint16(buf[0:2], uint16(hotX)) diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index bff37e6e7fb..529c3bae508 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -13,10 +13,15 @@ import ( // encoderLoop owns the capture → diff → encode → write pipeline. Running it // off the read loop prevents a slow encode (zlib full-frame, many dirty // tiles) from blocking inbound input events. +// +// Per-request panics are caught and turned into session teardown so a bug +// in one encoder path (a malformed capture frame, a zlib corner case) can +// only kill its own session, never the whole VNC server. func (s *session) encoderLoop(done chan<- struct{}) { defer close(done) for req := range s.encodeCh { - if err := s.processFBRequest(req); err != nil { + err := s.processFBRequestSafe(req) + if err != nil { s.log.Debugf("encode: %v", err) // On write/capture error, close the connection so messageLoop // exits and the session terminates cleanly. @@ -27,6 +32,25 @@ func (s *session) encoderLoop(done chan<- struct{}) { } } +// processFBRequestSafe wraps processFBRequest with a panic recover so a +// crash in encode/diff/compress paths surfaces as a session-only error +// instead of bringing down every peer's VNC sessions. The recover handler +// avoids any further dereference of session state (the panic itself may +// indicate a half-initialised session) so it can never re-panic. +func (s *session) processFBRequestSafe(req fbRequest) (err error) { + defer func() { + r := recover() + if r == nil { + return + } + err = fmt.Errorf("encoder panic: %v", r) + if s != nil && s.log != nil { + s.log.Errorf("encoder panic recovered: %v", r) + } + }() + return s.processFBRequest(req) +} + func (s *session) processFBRequest(req fbRequest) error { // Watch for resolution changes between cycles. When the capturer // reports a new size, tell the client via DesktopSize so it can diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 2d57927edac..1a486c5e6d9 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -32,6 +32,26 @@ const ( var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) +// vncNoisePrologueMagic must stay byte-identical to the server side +// (see client/vnc/server/handshake.go). Any drift here breaks every VNC +// handshake. +var vncNoisePrologueMagic = []byte("NetBird/VNC/Noise/v1\x00") + +// buildVNCNoisePrologue mirrors server.BuildVNCNoisePrologue. Both sides +// hash (magic || mode || u16len(username) || username) into the +// handshake hash; a client that lies about its mode/username in the +// cleartext header prefix produces a divergent prologue, the responder +// computes the truthful prologue from what it just read, and the AEAD +// MAC over the handshake state fails to verify. +func buildVNCNoisePrologue(mode byte, username string) []byte { + out := make([]byte, 0, len(vncNoisePrologueMagic)+1+2+len(username)) + out = append(out, vncNoisePrologueMagic...) + out = append(out, mode) + out = append(out, byte(len(username)>>8), byte(len(username))) + out = append(out, []byte(username)...) + return out +} + // sessionKeyStore retains per-session X25519 keypairs so the JS layer // only sees an opaque session id + the public key; the private key never // leaves wasm. @@ -437,6 +457,7 @@ func (p *VNCProxy) runNoiseHandshake(conn net.Conn, dest vncDestination) error { CipherSuite: vncNoiseSuite, Pattern: noise.HandshakeIK, Initiator: true, + Prologue: buildVNCNoisePrologue(dest.mode, dest.username), StaticKeypair: noise.DHKey{Private: dest.sessionPriv, Public: dest.sessionPub}, PeerStatic: dest.peerPubKey, }) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 8d410de77f9..28f4143095e 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -465,6 +465,26 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } + // Explicit defence-in-depth gate before any business logic: we already + // rely on AddPeer/SavePolicy to enforce the Peers.Create and + // Policies.Create permissions, but checking up-front means a future + // refactor that bypasses one of those calls can't silently widen the + // endpoint's authority. + if allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Peers, operations.Create); err != nil { + util.WriteError(r.Context(), status.NewPermissionValidationError(err), w) + return + } else if !allowed { + util.WriteError(r.Context(), status.NewPermissionDeniedError(), w) + return + } + if allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Policies, operations.Create); err != nil { + util.WriteError(r.Context(), status.NewPermissionValidationError(err), w) + return + } else if !allowed { + util.WriteError(r.Context(), status.NewPermissionDeniedError(), w) + return + } + var req api.PeerTemporaryAccessRequest err = json.NewDecoder(r.Body).Decode(&req) if err != nil { diff --git a/management/server/http/handlers/peers/temporary_access_permission_test.go b/management/server/http/handlers/peers/temporary_access_permission_test.go new file mode 100644 index 00000000000..6a318c8d078 --- /dev/null +++ b/management/server/http/handlers/peers/temporary_access_permission_test.go @@ -0,0 +1,106 @@ +package peers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestCreateTemporaryAccess_RejectsCallerWithoutPeersCreate verifies the +// defence-in-depth permission gate added to CreateTemporaryAccess: a user +// who cannot create peers must be turned away with 403 before any +// AccountManager call runs. Previously this endpoint relied entirely on +// SavePolicy/AddPeer's internal permission checks; the explicit gate +// makes sure a future refactor that bypasses one of those calls can't +// silently widen the endpoint's authority. +func TestCreateTemporaryAccess_RejectsCallerWithoutPeersCreate(t *testing.T) { + ctrl := gomock.NewController(t) + permMgr := permissions.NewMockManager(ctrl) + + // Caller lacks Peers.Create: handler must short-circuit before any + // AccountManager interaction. We deliberately leave accountManager + // nil so the test fails loudly if the handler tries to call it. + permMgr.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Create)). + Return(false, nil). + Times(1) + + h := &Handler{ + permissionsManager: permMgr, + } + + pubKey := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + body, _ := json.Marshal(api.PeerTemporaryAccessRequest{ + Name: "temp", + Rules: []string{"netbird-vnc"}, + WgPubKey: pubKey, + }) + + req := httptest.NewRequest(http.MethodPost, "/peers/peer-id/temporary-access", bytes.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"peerId": "peer-id"}) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: "regular_user", + Domain: "example.com", + AccountId: "acct1", + }) + + rec := httptest.NewRecorder() + h.CreateTemporaryAccess(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} + +// TestCreateTemporaryAccess_RejectsCallerWithoutPoliciesCreate covers +// the second leg of the gate: a user with Peers.Create but not +// Policies.Create must still be refused. Catches a misconfiguration +// where one permission is granted broadly but the other isn't. +func TestCreateTemporaryAccess_RejectsCallerWithoutPoliciesCreate(t *testing.T) { + ctrl := gomock.NewController(t) + permMgr := permissions.NewMockManager(ctrl) + + permMgr.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Create)). + Return(true, nil). + Times(1) + permMgr.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Policies), gomock.Eq(operations.Create)). + Return(false, nil). + Times(1) + + h := &Handler{ + permissionsManager: permMgr, + } + + body, _ := json.Marshal(api.PeerTemporaryAccessRequest{ + Name: "temp", + Rules: []string{"netbird-vnc"}, + }) + req := httptest.NewRequest(http.MethodPost, "/peers/peer-id/temporary-access", bytes.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"peerId": "peer-id"}) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: "regular_user", + Domain: "example.com", + AccountId: "acct1", + }) + + rec := httptest.NewRecorder() + h.CreateTemporaryAccess(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index 672dce3c691..8ce0c48f7b9 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -78,13 +78,17 @@ func applyResolvedRuleToState( } // handleVNCRule collects VNC authorized users and session pubkeys for a VNC -// policy rule. Bidirectional rules grant access in both directions. +// policy rule. Bidirectional rules grant access in both directions, so a +// peer that appears in the rule's sources also needs the SessionPubKey +// pushed (otherwise the Noise_IK handshake against that peer would fail +// because its authorizer wouldn't know the client's static key). func (cb ruleAuthCallbacks) handleVNCRule(rule *PolicyRule, peerInSources, peerInDestinations bool, state *peerConnResolveState) { - if !peerInDestinations && !(rule.Bidirectional && peerInSources) { + receivingPeer := peerInDestinations || (rule.Bidirectional && peerInSources) + if !receivingPeer { return } cb.collectVNCUsers(rule, state.vncAuthorizedUsers) - if peerInDestinations && rule.SessionPubKey != "" && rule.AuthorizedUser != "" { + if rule.SessionPubKey != "" && rule.AuthorizedUser != "" { state.vncSessionPubKeys = append(state.vncSessionPubKeys, VNCSessionPubKey{ PubKey: rule.SessionPubKey, UserID: rule.AuthorizedUser, diff --git a/management/server/types/policy_authorized_users_security_test.go b/management/server/types/policy_authorized_users_security_test.go new file mode 100644 index 00000000000..b040c9d2ffd --- /dev/null +++ b/management/server/types/policy_authorized_users_security_test.go @@ -0,0 +1,85 @@ +package types + +import "testing" + +// TestHandleVNCRule_BidirectionalDistributesPubkeyToSourcePeer covers the +// latent bug where a bidirectional VNC rule used to drop the +// SessionPubKey for the peer that appears only in sources, even though +// the rule explicitly grants access in both directions. Without the +// pubkey, the source peer's Noise_IK authorizer would not recognise the +// client's static key and Noise handshakes against it would fail. The +// fix in handleVNCRule must distribute the pubkey to either side of a +// bidirectional rule. +func TestHandleVNCRule_BidirectionalDistributesPubkeyToSourcePeer(t *testing.T) { + rule := &PolicyRule{ + Protocol: PolicyRuleProtocolNetbirdVNC, + Bidirectional: true, + AuthorizedUser: "user1", + SessionPubKey: "pubkey-base64", + SessionDisplayName: "Alice", + } + cb := ruleAuthCallbacks{ + collectVNCUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) {}, + } + state := &peerConnResolveState{ + vncAuthorizedUsers: make(map[string]map[string]struct{}), + } + + cb.handleVNCRule(rule, true /*peerInSources*/, false /*peerInDestinations*/, state) + + if len(state.vncSessionPubKeys) != 1 { + t.Fatalf("expected 1 session pubkey distributed to source peer of bidirectional rule, got %d", len(state.vncSessionPubKeys)) + } + if state.vncSessionPubKeys[0].PubKey != "pubkey-base64" { + t.Fatalf("unexpected pubkey: %q", state.vncSessionPubKeys[0].PubKey) + } +} + +// TestHandleVNCRule_UnidirectionalSourceGetsNoPubkey makes sure the fix +// above didn't widen pubkey distribution past the bidirectional case: +// a strictly source-to-destination rule still must not push the +// SessionPubKey to peers that appear only in sources. +func TestHandleVNCRule_UnidirectionalSourceGetsNoPubkey(t *testing.T) { + rule := &PolicyRule{ + Protocol: PolicyRuleProtocolNetbirdVNC, + Bidirectional: false, + AuthorizedUser: "user1", + SessionPubKey: "pubkey-base64", + } + cb := ruleAuthCallbacks{ + collectVNCUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) {}, + } + state := &peerConnResolveState{ + vncAuthorizedUsers: make(map[string]map[string]struct{}), + } + + cb.handleVNCRule(rule, true /*peerInSources*/, false /*peerInDestinations*/, state) + + if len(state.vncSessionPubKeys) != 0 { + t.Fatalf("expected NO pubkey for source peer of unidirectional rule, got %d", len(state.vncSessionPubKeys)) + } +} + +// TestHandleVNCRule_DestinationAlwaysGetsPubkey is the baseline case: +// destination peers must always receive the SessionPubKey since they're +// the ones that need to authenticate the incoming Noise handshake. +func TestHandleVNCRule_DestinationAlwaysGetsPubkey(t *testing.T) { + rule := &PolicyRule{ + Protocol: PolicyRuleProtocolNetbirdVNC, + Bidirectional: false, + AuthorizedUser: "user1", + SessionPubKey: "pubkey-base64", + } + cb := ruleAuthCallbacks{ + collectVNCUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) {}, + } + state := &peerConnResolveState{ + vncAuthorizedUsers: make(map[string]map[string]struct{}), + } + + cb.handleVNCRule(rule, false /*peerInSources*/, true /*peerInDestinations*/, state) + + if len(state.vncSessionPubKeys) != 1 { + t.Fatalf("expected 1 session pubkey for destination peer, got %d", len(state.vncSessionPubKeys)) + } +} From 4e3e3ce6d3408960faac220aad37f99f98ee462d Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 24 May 2026 16:32:30 +0200 Subject: [PATCH 084/151] Surface VNC initiator in status, clarify proxy logs, dampen capture noise --- client/internal/engine_vnc_stub.go | 10 ++++++-- client/proto/daemon.pb.go | 17 ++++++++++--- client/proto/daemon.proto | 3 +++ client/server/server.go | 1 + client/status/status.go | 33 +++++++++++++++++------- client/vnc/server/agent_ipc.go | 19 +++++++++++--- client/vnc/server/capture_windows.go | 38 +++++++++++++++++++++++++--- client/vnc/server/server.go | 9 +++++++ client/wasm/internal/vnc/proxy.go | 4 +-- 9 files changed, 111 insertions(+), 23 deletions(-) diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go index 4c8d7cd5578..b362063808e 100644 --- a/client/internal/engine_vnc_stub.go +++ b/client/internal/engine_vnc_stub.go @@ -3,6 +3,8 @@ package internal import ( + log "github.com/sirupsen/logrus" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) @@ -10,8 +12,12 @@ type vncServer interface{} func (e *Engine) updateVNC() error { return nil } -func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) { - // no-op on platforms without a VNC server +func (e *Engine) updateVNCServerAuth(auth *mgmProto.VNCAuth) { + if auth == nil { + return + } + log.Debugf("ignoring VNC auth push on platform without a VNC server: %d session pubkeys, %d authorized users", + len(auth.GetSessionPubKeys()), len(auth.GetAuthorizedUsers())) } func (e *Engine) stopVNCServer() error { return nil } diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index d5cb6927742..08b73a25ef7 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2128,7 +2128,10 @@ type VNCSessionInfo struct { Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` // userID is the Noise-verified session identity (hashed user ID from // the ACL session-key entry), empty when auth is disabled. - UserID string `protobuf:"bytes,4,opt,name=userID,proto3" json:"userID,omitempty"` + UserID string `protobuf:"bytes,4,opt,name=userID,proto3" json:"userID,omitempty"` + // initiator is the human-readable display name of the dashboard user + // who minted the SessionPubKey, when known. + Initiator string `protobuf:"bytes,5,opt,name=initiator,proto3" json:"initiator,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2191,6 +2194,13 @@ func (x *VNCSessionInfo) GetUserID() string { return "" } +func (x *VNCSessionInfo) GetInitiator() string { + if x != nil { + return x.Initiator + } + return "" +} + // VNCServerState contains the latest state of the VNC server type VNCServerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6717,12 +6727,13 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"~\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\x9c\x01\n" + "\x0eVNCSessionInfo\x12$\n" + "\rremoteAddress\x18\x01 \x01(\tR\rremoteAddress\x12\x12\n" + "\x04mode\x18\x02 \x01(\tR\x04mode\x12\x1a\n" + "\busername\x18\x03 \x01(\tR\busername\x12\x16\n" + - "\x06userID\x18\x04 \x01(\tR\x06userID\"^\n" + + "\x06userID\x18\x04 \x01(\tR\x06userID\x12\x1c\n" + + "\tinitiator\x18\x05 \x01(\tR\tinitiator\"^\n" + "\x0eVNCServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + "\bsessions\x18\x02 \x03(\v2\x16.daemon.VNCSessionInfoR\bsessions\"\xef\x04\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 9592e3a753f..c253de2edc5 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -418,6 +418,9 @@ message VNCSessionInfo { // userID is the Noise-verified session identity (hashed user ID from // the ACL session-key entry), empty when auth is disabled. string userID = 4; + // initiator is the human-readable display name of the dashboard user + // who minted the SessionPubKey, when known. + string initiator = 5; } // VNCServerState contains the latest state of the VNC server diff --git a/client/server/server.go b/client/server/server.go index 143bc817701..b102cf63e90 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1205,6 +1205,7 @@ func (s *Server) getVNCServerState() *proto.VNCServerState { Mode: sess.Mode, Username: sess.Username, UserID: sess.UserID, + Initiator: sess.Initiator, }) } return &proto.VNCServerState{ diff --git a/client/status/status.go b/client/status/status.go index 30760efe15a..6b88e891a7d 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -136,6 +136,7 @@ type VNCSessionOutput struct { Mode string `json:"mode" yaml:"mode"` Username string `json:"username,omitempty" yaml:"username,omitempty"` UserID string `json:"userID,omitempty" yaml:"userID,omitempty"` + Initiator string `json:"initiator,omitempty" yaml:"initiator,omitempty"` } type VNCServerStateOutput struct { @@ -297,6 +298,7 @@ func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput { Mode: sess.GetMode(), Username: sess.GetUsername(), UserID: sess.GetUserID(), + Initiator: sess.GetInitiator(), }) } return VNCServerStateOutput{ @@ -582,15 +584,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS if showSSHSessions && vncSessionCount > 0 { for _, sess := range o.VNCServerState.Sessions { - var line string - if sess.UserID != "" { - line = fmt.Sprintf("[%s@%s -> %s] mode=%s", - sess.UserID, sess.RemoteAddress, sess.Username, sess.Mode) - } else { - line = fmt.Sprintf("[%s] mode=%s user=%s", - sess.RemoteAddress, sess.Mode, sess.Username) - } - vncServerStatus += "\n " + line + vncServerStatus += "\n " + formatVNCSessionLine(sess) } } } @@ -1004,6 +998,26 @@ func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { } } +// formatVNCSessionLine renders a single VNC session row for the detailed +// status output. The leading slot identifies the initiator (display name +// when known, hashed UserID otherwise); the post-arrow slot is the OS +// user the session targets and is omitted in attach mode where the +// destination is the current console user (unknown to the daemon). +func formatVNCSessionLine(sess VNCSessionOutput) string { + who := sess.Initiator + if who == "" { + who = sess.UserID + } + prefix := sess.RemoteAddress + if who != "" { + prefix = fmt.Sprintf("%s@%s", who, sess.RemoteAddress) + } + if sess.Username != "" { + return fmt.Sprintf("[%s -> %s] mode=%s", prefix, sess.Username, sess.Mode) + } + return fmt.Sprintf("[%s] mode=%s", prefix, sess.Mode) +} + func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { for i, peer := range overview.Peers.Details { peer := peer @@ -1077,5 +1091,6 @@ func anonymizeServerSessions(a *anonymize.Anonymizer, overview *OutputOverview) overview.VNCServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, sess.RemoteAddress) overview.VNCServerState.Sessions[i].Username = a.AnonymizeString(sess.Username) overview.VNCServerState.Sessions[i].UserID = a.AnonymizeString(sess.UserID) + overview.VNCServerState.Sessions[i].Initiator = a.AnonymizeString(sess.Initiator) } } diff --git a/client/vnc/server/agent_ipc.go b/client/vnc/server/agent_ipc.go index aba3e7da5b5..7bfb988de50 100644 --- a/client/vnc/server/agent_ipc.go +++ b/client/vnc/server/agent_ipc.go @@ -68,7 +68,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { return } - authedLog, _, ok := s.authorizeSession(conn, header, connLog) + authedLog, sessionUserID, ok := s.authorizeSession(conn, header, connLog) if !ok { authedLog.Info("VNC connection rejected: auth failed") return @@ -101,6 +101,19 @@ func (s *Server) handleServiceConnection(conn net.Conn, sa sessionAgent) { return } + var initiator string + if s.authorizer != nil { + initiator = s.authorizer.LookupSessionDisplayName(header.clientStatic) + } + sessionID := s.addSession(ActiveSessionInfo{ + RemoteAddress: conn.RemoteAddr().String(), + Mode: modeString(header.mode), + Username: header.username, + UserID: sessionUserID, + Initiator: initiator, + }, conn) + defer s.removeSession(sessionID) + replayConn := &prefixConn{ Reader: io.MultiReader(&headerBuf, conn), Conn: conn, @@ -198,8 +211,8 @@ func proxyToAgent(ctx context.Context, client net.Conn, socketPath, authToken st log.Debugf("proxy %s: %d bytes, err=%v", label, n, err) done <- struct{}{} } - go cp("client→agent", agentConn, client) - go cp("agent→client", client, agentConn) + go cp("client->agent", agentConn, client) + go cp("agent->client", client, agentConn) <-done return nil } diff --git a/client/vnc/server/capture_windows.go b/client/vnc/server/capture_windows.go index 8afbad1103d..11600bded9d 100644 --- a/client/vnc/server/capture_windows.go +++ b/client/vnc/server/capture_windows.go @@ -445,6 +445,14 @@ type captureWorker struct { lastDesktop string nextInitRetry time.Time cursor cursorSampler + // lastBackend records the last capturer kind that came out of + // createCapturer ("dxgi" or "gdi"); used to demote repeat "using X" + // and DXGI-unavailable logs to debug when nothing changed. + lastBackend string + // lastDXGIErr is the textual DXGI failure printed in the most recent + // fallback warning; suppresses repeat warns when DXGI keeps failing + // the same way across desktop changes (login -> lock -> login). + lastDXGIErr string } // handleNextRequest waits for either shutdown or a capture request and runs @@ -503,9 +511,14 @@ func (w *captureWorker) prepCapturer() (frameCapturer, error) { w.cap = fc sw, sh := screenSize() w.c.mu.Lock() + sizeChanged := w.c.w != sw || w.c.h != sh w.c.w, w.c.h = sw, sh w.c.mu.Unlock() - log.Infof("screen capturer ready: %dx%d", sw, sh) + if sizeChanged { + log.Infof("screen capturer ready: %dx%d", sw, sh) + } else { + log.Debugf("screen capturer ready: %dx%d", sw, sh) + } return w.cap, nil } @@ -536,15 +549,32 @@ func (w *captureWorker) refreshDesktop() error { func (w *captureWorker) createCapturer() (frameCapturer, error) { dc, err := newDXGICapturer() if err == nil { - log.Info("using DXGI Desktop Duplication for capture") + if w.lastBackend != "dxgi" { + log.Info("using DXGI Desktop Duplication for capture") + } else { + log.Debug("using DXGI Desktop Duplication for capture") + } + w.lastBackend = "dxgi" + w.lastDXGIErr = "" return dc, nil } - log.Warnf("DXGI Desktop Duplication unavailable, falling back to slower GDI BitBlt: %v", err) + errStr := err.Error() + if errStr != w.lastDXGIErr { + log.Warnf("DXGI Desktop Duplication unavailable, falling back to slower GDI BitBlt: %v", err) + w.lastDXGIErr = errStr + } else { + log.Debugf("DXGI Desktop Duplication still unavailable, falling back to slower GDI BitBlt: %v", err) + } gc, err := newGDICapturer() if err != nil { return nil, err } - log.Info("using GDI BitBlt for capture") + if w.lastBackend != "gdi" { + log.Info("using GDI BitBlt for capture") + } else { + log.Debug("using GDI BitBlt for capture") + } + w.lastBackend = "gdi" return gc, nil } diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 4c31aae2165..a19fbcdc290 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -246,6 +246,10 @@ type ActiveSessionInfo struct { // UserID is the authenticated session identity (hashed user ID from // the Noise_IK static-key registration), empty when auth is disabled. UserID string + // Initiator is the dashboard-supplied display name of the user who + // minted the SessionPubKey, when known. Empty when auth is disabled + // or the authorizer has no display-name mapping. + Initiator string } // vncSession provides capturer and injector for a virtual display session. @@ -852,11 +856,16 @@ func (s *Server) handleConnection(conn net.Conn) { return } + var initiator string + if s.authorizer != nil { + initiator = s.authorizer.LookupSessionDisplayName(header.clientStatic) + } sessionID := s.addSession(ActiveSessionInfo{ RemoteAddress: conn.RemoteAddr().String(), Mode: modeString(header.mode), Username: header.username, UserID: sessionUserID, + Initiator: initiator, }, conn) defer s.removeSession(sessionID) diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 1a486c5e6d9..09314f438bf 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -480,10 +480,10 @@ func (p *VNCProxy) runNoiseHandshake(conn net.Conn, dest vncDestination) error { defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck msg2 := make([]byte, noiseResponderMsgLen) if _, err := io.ReadFull(conn, msg2); err != nil { - return fmt.Errorf("read noise msg2: %w", err) + return fmt.Errorf("read noise msg2 from server: %w", err) } if _, _, _, err := state.ReadMessage(nil, msg2); err != nil { - return fmt.Errorf("noise read msg2: %w", err) + return fmt.Errorf("decrypt noise msg2 (peer pubkey mismatch or session revoked): %w", err) } return nil } From bf2fb2fd441d92fb3bcf896e4173534be4a9d502 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 24 May 2026 18:52:57 +0200 Subject: [PATCH 085/151] Address CodeRabbit review on embedded VNC PR --- client/internal/engine_vnc.go | 20 +++++++++++++++---- client/vnc/server/agent_windows.go | 6 +++++- client/vnc/server/noise_auth_test.go | 2 ++ client/vnc/server/server.go | 14 ++++++++++++- client/vnc/server/server_windows.go | 15 ++++++++++---- .../server/types/networkmap_components.go | 10 ++++++++-- .../server/types/policy_authorized_users.go | 5 +++-- 7 files changed, 58 insertions(+), 14 deletions(-) diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index ab1f4f216dc..9cebd014e75 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -155,8 +155,10 @@ func (e *Engine) startVNCServer() error { } // updateVNCServerAuth updates VNC fine-grained access control from management. +// A nil vncAuth clears all authorized users and session pubkeys so management +// can revoke access by omitting the field on the next sync. func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { - if vncAuth == nil || e.vncSrv == nil { + if e.vncSrv == nil { return } @@ -165,6 +167,11 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { return } + if vncAuth == nil { + vncSrv.UpdateVNCAuth(&sshauth.Config{}) + return + } + protoUsers := vncAuth.GetAuthorizedUsers() authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers)) for i, hash := range protoUsers { @@ -207,12 +214,17 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { } // GetVNCServerStatus returns whether the VNC server is running and the list -// of active VNC sessions. +// of active VNC sessions. The pointer is captured under syncMsgMux so a +// concurrent updateVNC/stopVNCServer cannot swap it out between the nil +// check and the ActiveSessions call. func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) { - if e.vncSrv == nil { + e.syncMsgMux.Lock() + vncSrv := e.vncSrv + e.syncMsgMux.Unlock() + if vncSrv == nil { return false, nil } - return true, e.vncSrv.ActiveSessions() + return true, vncSrv.ActiveSessions() } func (e *Engine) stopVNCServer() error { diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 64192d46973..74fa7e0dcc7 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -324,7 +324,11 @@ func spawnAgentInSession(sessionID uint32, socketPath, authToken string, jobHand } if _, err := windows.ResumeThread(pi.Thread); err != nil { - log.Warnf("resume agent main thread: %v", err) + _ = windows.CloseHandle(pi.Thread) + _ = windows.TerminateProcess(pi.Process, 1) + _ = windows.CloseHandle(pi.Process) + _ = windows.CloseHandle(stderrRead) + return 0, fmt.Errorf("ResumeThread: %w", err) } _ = windows.CloseHandle(pi.Thread) diff --git a/client/vnc/server/noise_auth_test.go b/client/vnc/server/noise_auth_test.go index 9ec30d4424e..aa9baad4056 100644 --- a/client/vnc/server/noise_auth_test.go +++ b/client/vnc/server/noise_auth_test.go @@ -243,6 +243,7 @@ func TestNoise_WrongServerStatic_HandshakeFails(t *testing.T) { CipherSuite: vncNoiseSuite, Pattern: noise.HandshakeIK, Initiator: true, + Prologue: BuildVNCNoisePrologue(ModeAttach, ""), StaticKeypair: clientKey, PeerStatic: bogusServerKey.Public, }) @@ -382,6 +383,7 @@ func TestNoise_NoIdentityKey_FailsClosed(t *testing.T) { CipherSuite: vncNoiseSuite, Pattern: noise.HandshakeIK, Initiator: true, + Prologue: BuildVNCNoisePrologue(ModeAttach, ""), StaticKeypair: clientKey, PeerStatic: fakeServerKey.Public, }) diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index a19fbcdc290..d14584625e2 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -180,6 +180,10 @@ type Server struct { netstackNet *netstack.Net // agentToken holds the raw token bytes for agent-mode auth. agentToken []byte + // invalidAgentToken latches when AgentTokenHex was provided but failed + // to decode. Start refuses to listen in that case so the daemon never + // silently downgrades the local IPC hop to unauthenticated access. + invalidAgentToken bool // identityKey is the daemon's static X25519 private key used in the // Noise_IK handshake. Nil disables the handshake. identityKey []byte @@ -356,6 +360,7 @@ func New(cfg Config) *Server { if b, err := hex.DecodeString(cfg.AgentTokenHex); err == nil { s.agentToken = b } else { + s.invalidAgentToken = true s.log.Warnf("invalid agent token: %v", err) } } @@ -578,6 +583,9 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.P if s.listener != nil { return fmt.Errorf("server already running") } + if s.invalidAgentToken { + return fmt.Errorf("invalid agent token configuration") + } s.ctx, s.cancel = context.WithCancel(ctx) s.vmgr = s.platformSessionManager() @@ -686,13 +694,17 @@ func (s *Server) acceptLoop() { continue } + // Track before any early-reject path so a concurrent Stop's + // closeActiveSessions snapshot can never miss a just-accepted + // socket and let it survive shutdown. + s.trackConn(conn) if !s.tryAcquireConnSlot() { + s.untrackConn(conn) s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns) _ = conn.Close() continue } enableTCPKeepAlive(conn, s.log) - s.trackConn(conn) go func(c net.Conn) { defer s.releaseConnSlot() defer s.untrackConn(c) diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 7ad88eef7f4..0d13dc0457d 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -54,6 +54,11 @@ func sasSecurityAttributes() (*windows.SecurityAttributes, error) { type sasOriginalState struct { had bool // true if the value existed before we wrote value uint32 // its prior DWORD value, if had == true + // captured stays true once we have read the genuine pre-enable state + // for the first time, so a second enableSoftwareSAS call (e.g. after + // a daemon restart with no intervening disable) cannot overwrite the + // snapshot with our own forced value. + captured bool } var savedSASState sasOriginalState @@ -74,10 +79,12 @@ func enableSoftwareSAS() { } defer key.Close() - if prev, _, err := key.GetIntegerValue("SoftwareSASGeneration"); err == nil { - savedSASState = sasOriginalState{had: true, value: uint32(prev)} - } else { - savedSASState = sasOriginalState{had: false} + if !savedSASState.captured { + if prev, _, err := key.GetIntegerValue("SoftwareSASGeneration"); err == nil { + savedSASState = sasOriginalState{had: true, value: uint32(prev), captured: true} + } else { + savedSASState = sasOriginalState{had: false, captured: true} + } } if err := key.SetDWordValue("SoftwareSASGeneration", 1); err != nil { diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index 0b9a2d505d2..b8572fc7b69 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -243,7 +243,7 @@ func (c *NetworkMapComponents) resolveRuleEndpoint( postureChecks []string, ) ([]*nbpeer.Peer, bool) { if resource.Type == ResourceTypePeer && resource.ID != "" { - return c.getPeerFromResource(resource, peerID) + return c.getPeerFromResource(resource, peerID, postureChecks) } return c.getAllPeersFromGroups(groups, peerID, postureChecks) } @@ -385,8 +385,11 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) [] return ids } -func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string, postureChecks []string) ([]*nbpeer.Peer, bool) { if resource.ID == peerID { + if len(postureChecks) > 0 && !c.ValidatePostureChecksOnPeer(peerID, postureChecks) { + return []*nbpeer.Peer{}, false + } return []*nbpeer.Peer{}, true } @@ -394,6 +397,9 @@ func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID str if peerInfo == nil { return []*nbpeer.Peer{}, false } + if len(postureChecks) > 0 && !c.ValidatePostureChecksOnPeer(resource.ID, postureChecks) { + return []*nbpeer.Peer{}, false + } return []*nbpeer.Peer{peerInfo}, false } diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index 8ce0c48f7b9..486446f9534 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -59,9 +59,10 @@ func applyResolvedRuleToState( ) { emitRuleDirections(rule, sourcePeers, destPeers, peerInSources, peerInDestinations, generateResources) + receivingPeer := peerInDestinations || (rule.Bidirectional && peerInSources) switch { case rule.Protocol == PolicyRuleProtocolNetbirdSSH: - if !peerInDestinations { + if !receivingPeer { return } state.sshEnabled = true @@ -69,7 +70,7 @@ func applyResolvedRuleToState( case rule.Protocol == PolicyRuleProtocolNetbirdVNC: cb.handleVNCRule(rule, peerInSources, peerInDestinations, state) case policyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled: - if !peerInDestinations { + if !receivingPeer { return } state.sshEnabled = true From 2f67841b1eb950c7673ba37f9fd9f756757a7bff Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 25 May 2026 11:03:51 +0200 Subject: [PATCH 086/151] Reuse /var/run/netbird as VNC agent socket parent via configs.RuntimeDir --- client/configs/configs.go | 21 ++++++++++++---- client/vnc/server/agent_darwin.go | 41 ++++++++++++++----------------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/client/configs/configs.go b/client/configs/configs.go index 8f9c3ba2870..72e910527ba 100644 --- a/client/configs/configs.go +++ b/client/configs/configs.go @@ -6,19 +6,30 @@ import ( "runtime" ) -var StateDir string +var ( + // StateDir holds persistent state (config, profiles, install metadata). + StateDir string + // RuntimeDir holds ephemeral artifacts that should not survive reboot, + // such as Unix sockets for daemon and per-session IPC. Empty on + // platforms without a conventional /var/run-style location. + RuntimeDir string +) func init() { - StateDir = os.Getenv("NB_STATE_DIR") - if StateDir != "" { - return - } switch runtime.GOOS { case "windows": StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird") case "darwin", "linux": StateDir = "/var/lib/netbird" + RuntimeDir = "/var/run/netbird" case "freebsd", "openbsd", "netbsd", "dragonfly": StateDir = "/var/db/netbird" + RuntimeDir = "/var/run/netbird" + } + if v := os.Getenv("NB_STATE_DIR"); v != "" { + StateDir = v + } + if v := os.Getenv("NB_RUNTIME_DIR"); v != "" { + RuntimeDir = v } } diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index 3c470f9daf7..0d6b371b5be 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -17,6 +17,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" + + "github.com/netbirdio/netbird/client/configs" ) // darwinAgentManager spawns a per-user VNC agent on demand and keeps it @@ -133,37 +135,32 @@ func (m *darwinAgentManager) Resolve(ctx context.Context) (string, string, uint3 return socketPath, token, consoleUID, nil } -// agentSocketParentDir is the root the daemon creates (as root, mode 0755) -// to hold per-uid agent-socket subdirectories. Keeping it under -// /var/run/netbird-vnc (rather than /tmp) means a non-root local user -// cannot squat the socket path: only root can create the parent, and -// only the target user (plus root) can write inside the per-uid subdir. -const agentSocketParentDir = "/var/run/netbird-vnc" - -// prepareAgentSocketDir creates (and tightens permissions on) a per-uid -// subdirectory the agent will bind its socket inside, returning the -// directory path. The subdirectory is owned by uid with mode 0700, so -// the only writers are the target user and root. The parent is created -// root-owned with mode 0755 if it doesn't already exist. Symlinks at -// the per-uid level are refused (replaced with a fresh directory) to -// avoid a low-priv user redirecting our chown. +// prepareAgentSocketDir creates a per-uid subdirectory under the netbird +// runtime directory where the agent will bind its Unix socket. The leaf is +// owned by uid with mode 0700, so only the target user and root can write +// there. The parent is created root-owned with mode 0755 if missing. +// Symlinks at the per-uid level are refused (replaced with a fresh +// directory) so a low-priv user cannot redirect the chown that follows. func prepareAgentSocketDir(uid uint32) (string, error) { - if err := os.MkdirAll(agentSocketParentDir, 0o755); err != nil { - return "", fmt.Errorf("mkdir %s: %w", agentSocketParentDir, err) + parent := configs.RuntimeDir + if parent == "" { + return "", fmt.Errorf("no runtime directory configured for this platform") + } + if err := os.MkdirAll(parent, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", parent, err) } - // Refuse to use the parent if it's a symlink or not owned by root. - pInfo, err := os.Lstat(agentSocketParentDir) + pInfo, err := os.Lstat(parent) if err != nil { - return "", fmt.Errorf("lstat %s: %w", agentSocketParentDir, err) + return "", fmt.Errorf("lstat %s: %w", parent, err) } if pInfo.Mode()&os.ModeSymlink != 0 { - return "", fmt.Errorf("%s is a symlink", agentSocketParentDir) + return "", fmt.Errorf("%s is a symlink", parent) } if st, ok := pInfo.Sys().(*syscall.Stat_t); ok && st.Uid != 0 { - return "", fmt.Errorf("%s not owned by root (uid=%d)", agentSocketParentDir, st.Uid) + return "", fmt.Errorf("%s not owned by root (uid=%d)", parent, st.Uid) } - subdir := fmt.Sprintf("%s/%d", agentSocketParentDir, uid) + subdir := fmt.Sprintf("%s/vnc-%d", parent, uid) // If a leftover entry exists, refuse it unless it's a real dir owned // by the right uid with strict perms: otherwise remove and recreate // from scratch under our control. Using os.Lstat (not Stat) so a From 65f302b698d5709f97d9ab330551b598e6d1ebe1 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 25 May 2026 13:26:29 +0200 Subject: [PATCH 087/151] Authenticate virtual X11 sessions with per-session MIT-MAGIC-COOKIE-1 --- client/internal/engine_vnc_x11.go | 4 +- client/vnc/server/capture_x11.go | 23 ++++-- client/vnc/server/input_x11.go | 31 ++++++-- client/vnc/server/virtual_x11.go | 111 +++++++++++++++++++++++--- client/vnc/server/xauth_linux.go | 124 ++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 client/vnc/server/xauth_linux.go diff --git a/client/internal/engine_vnc_x11.go b/client/internal/engine_vnc_x11.go index d74bd17ab23..3b0ddf8ac87 100644 --- a/client/internal/engine_vnc_x11.go +++ b/client/internal/engine_vnc_x11.go @@ -11,9 +11,9 @@ import ( func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { // Prefer X11 when an X server is reachable. NewX11InputInjector probes // DISPLAY (and /proc) eagerly, so a non-nil error here means no X. - injector, err := vncserver.NewX11InputInjector("") + injector, err := vncserver.NewX11InputInjector("", "", "") if err == nil { - return vncserver.NewX11Poller(""), injector, true + return vncserver.NewX11Poller("", ""), injector, true } log.Debugf("VNC: X11 not available: %v", err) diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index c7634bc2eaa..bdfaf7c4286 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -210,7 +210,8 @@ func splitNull(data []byte) [][]byte { } // NewX11Capturer connects to the X11 display and sets up shared memory capture. -func NewX11Capturer(display string) (*X11Capturer, error) { +// Empty cookieHex falls back to XAUTHORITY env lookup. +func NewX11Capturer(display, cookieHex string) (*X11Capturer, error) { if display == "" { detectX11Display() display = os.Getenv(envDisplay) @@ -219,7 +220,13 @@ func NewX11Capturer(display string) (*X11Capturer, error) { return nil, fmt.Errorf("DISPLAY not set and no Xorg process found") } - conn, err := xgb.NewConnDisplay(display) + var conn *xgb.Conn + var err error + if cookieHex != "" { + conn, err = dialXUnixWithCookie(display, cookieHex) + } else { + conn, err = xgb.NewConnDisplay(display) + } if err != nil { return nil, fmt.Errorf("connect to X11 display %s: %w", display, err) } @@ -370,6 +377,8 @@ type X11Poller struct { clients atomic.Int32 display string + // cookieHex authenticates the X11 connection; empty falls back to XAUTHORITY env. + cookieHex string } // initRetryBackoff gates capturer re-init attempts after a failure so we @@ -377,10 +386,12 @@ type X11Poller struct { const initRetryBackoff = 2 * time.Second // NewX11Poller creates a lazy on-demand capturer for the given X display. -func NewX11Poller(display string) *X11Poller { +// Empty cookieHex falls back to XAUTHORITY env lookup. +func NewX11Poller(display, cookieHex string) *X11Poller { return &X11Poller{ - display: display, - done: make(chan struct{}), + display: display, + cookieHex: cookieHex, + done: make(chan struct{}), } } @@ -521,7 +532,7 @@ func (p *X11Poller) ensureCapturerLocked() error { if time.Now().Before(p.initBackoffUntil) { return fmt.Errorf("x11 capturer unavailable (retry scheduled)") } - c, err := NewX11Capturer(p.display) + c, err := NewX11Capturer(p.display, p.cookieHex) if err != nil { p.initBackoffUntil = time.Now().Add(initRetryBackoff) log.Debugf("X11 capturer: %v", err) diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index ed44aff93ec..d0588470eba 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -25,10 +25,13 @@ type X11InputInjector struct { lastButtons uint16 clipboardTool string clipboardToolName string + // authFile points xclip/xsel at the per-session Xauthority via XAUTHORITY env. + authFile string } // NewX11InputInjector connects to the X11 display and initializes XTest. -func NewX11InputInjector(display string) (*X11InputInjector, error) { +// Empty cookieHex/authFile fall back to XAUTHORITY env lookup. +func NewX11InputInjector(display, cookieHex, authFile string) (*X11InputInjector, error) { detectX11Display() if display == "" { @@ -38,7 +41,13 @@ func NewX11InputInjector(display string) (*X11InputInjector, error) { return nil, fmt.Errorf("DISPLAY not set and no Xorg process found") } - conn, err := xgb.NewConnDisplay(display) + var conn *xgb.Conn + var err error + if cookieHex != "" { + conn, err = dialXUnixWithCookie(display, cookieHex) + } else { + conn, err = xgb.NewConnDisplay(display) + } if err != nil { return nil, fmt.Errorf("connect to X11 display %s: %w", display, err) } @@ -56,10 +65,11 @@ func NewX11InputInjector(display string) (*X11InputInjector, error) { screen := setup.Roots[0] inj := &X11InputInjector{ - conn: conn, - root: screen.Root, - screen: &screen, - display: display, + conn: conn, + root: screen.Root, + screen: &screen, + display: display, + authFile: authFile, } inj.cacheKeyboardMapping() inj.resolveClipboardTool() @@ -297,8 +307,13 @@ func (x *X11InputInjector) GetClipboard() string { func (x *X11InputInjector) clipboardEnv() []string { env := []string{envDisplay + "=" + x.display} - if auth := os.Getenv(envXAuthority); auth != "" { - env = append(env, envXAuthority+"="+auth) + switch { + case x.authFile != "": + env = append(env, envXAuthority+"="+x.authFile) + default: + if auth := os.Getenv(envXAuthority); auth != "" { + env = append(env, envXAuthority+"="+auth) + } } return env } diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go index 9f8886bc299..f19dbdbf5b1 100644 --- a/client/vnc/server/virtual_x11.go +++ b/client/vnc/server/virtual_x11.go @@ -15,6 +15,8 @@ import ( "time" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/configs" ) // VirtualSession manages a virtual X11 display (Xvfb) with a desktop session @@ -25,6 +27,9 @@ const ( defaultSessionWidth uint16 = 1280 defaultSessionHeight uint16 = 800 + + vncXAuthSubdir = "vnc-xauth" + vncXAuthNameFmt = "X%s-%d" ) type VirtualSession struct { @@ -44,7 +49,12 @@ type VirtualSession struct { stopped bool clients int idleTimer *time.Timer - onIdle func() // called when idle timeout fires or Xvfb dies + // onIdle fires when the idle timeout elapses or the X server dies. + onIdle func() + // cookieHex authenticates X clients against our Xvfb instance. + cookieHex string + // authFile backs cookieHex on disk for Xvfb (-auth) and the desktop env. + authFile string } // StartVirtualSession creates and starts a virtual X11 session for the given @@ -114,28 +124,36 @@ func (vs *VirtualSession) start() error { } vs.display = display + if err := vs.prepareXAuth(); err != nil { + return fmt.Errorf("prepare xauth: %w", err) + } + if err := vs.startXvfb(); err != nil { + vs.cleanupXAuth() return err } socketPath := fmt.Sprintf("%s/X%s", x11SocketDir, vs.display[1:]) if err := waitForPath(socketPath, 5*time.Second); err != nil { vs.stopXvfb() + vs.cleanupXAuth() return fmt.Errorf("wait for X11 socket %s: %w", socketPath, err) } - // Grant the target user access to the display via xhost. - xhostCmd := exec.Command("xhost", "+SI:localuser:"+vs.user.Username) - xhostCmd.Env = []string{envDisplay + "=" + vs.display} - if out, err := xhostCmd.CombinedOutput(); err != nil { - vs.log.Debugf("xhost: %s (%v)", strings.TrimSpace(string(out)), err) + // Restrict the X socket to root and the target user. + if err := os.Chown(socketPath, int(vs.uid), int(vs.gid)); err != nil { + vs.log.Debugf("chown X socket: %v", err) + } + if err := os.Chmod(socketPath, 0700); err != nil { + vs.log.Debugf("chmod X socket: %v", err) } - vs.poller = NewX11Poller(vs.display) + vs.poller = NewX11Poller(vs.display, vs.cookieHex) - injector, err := NewX11InputInjector(vs.display) + injector, err := NewX11InputInjector(vs.display, vs.cookieHex, vs.authFile) if err != nil { vs.stopXvfb() + vs.cleanupXAuth() return fmt.Errorf("create X11 injector for %s: %w", vs.display, err) } vs.injector = injector @@ -143,6 +161,7 @@ func (vs *VirtualSession) start() error { if err := vs.startDesktop(); err != nil { vs.injector.Close() vs.stopXvfb() + vs.cleanupXAuth() return fmt.Errorf("start desktop: %w", err) } @@ -247,6 +266,7 @@ func (vs *VirtualSession) Stop() { vs.stopDesktop() vs.stopXvfb() + vs.cleanupXAuth() vs.log.Info("virtual session stopped") } @@ -263,6 +283,7 @@ func (vs *VirtualSession) startXvfbDirect() error { vs.xvfb = exec.Command("Xvfb", vs.display, "-screen", "0", geom, "-nolisten", "tcp", + "-auth", vs.authFile, ) vs.xvfb.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGTERM} @@ -318,6 +339,7 @@ EndSection "-config", confPath, "-noreset", "-nolisten", "tcp", + "-auth", vs.authFile, ) vs.xvfb.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGTERM} @@ -357,6 +379,7 @@ func (vs *VirtualSession) monitorXvfb() { vs.injector.Close() } vs.stopDesktop() + vs.cleanupXAuth() } onIdle := vs.onIdle vs.mu.Unlock() @@ -436,6 +459,7 @@ func (vs *VirtualSession) monitorDesktop() { vs.injector.Close() } vs.stopXvfb() + vs.cleanupXAuth() } onIdle := vs.onIdle vs.mu.Unlock() @@ -459,7 +483,7 @@ func (vs *VirtualSession) stopDesktop() { } func (vs *VirtualSession) buildUserEnv() []string { - return []string{ + env := []string{ envDisplay + "=" + vs.display, "HOME=" + vs.user.HomeDir, "USER=" + vs.user.Username, @@ -469,6 +493,46 @@ func (vs *VirtualSession) buildUserEnv() []string { "XDG_RUNTIME_DIR=/run/user/" + vs.user.Uid, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + vs.user.Uid + "/bus", } + if vs.authFile != "" { + env = append(env, envXAuthority+"="+vs.authFile) + } + return env +} + +// prepareXAuth generates a per-session cookie and writes it to an +// Xauthority file owned by the target user. +func (vs *VirtualSession) prepareXAuth() error { + if configs.RuntimeDir == "" { + return fmt.Errorf("no runtime directory configured for this platform") + } + cookie, hexStr, err := generateXAuthCookie() + if err != nil { + return err + } + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("hostname: %w", err) + } + displayNum := strings.TrimPrefix(vs.display, ":") + authPath := filepath.Join(configs.RuntimeDir, vncXAuthSubdir, fmt.Sprintf(vncXAuthNameFmt, displayNum, vs.uid)) + if err := writeXAuthFile(authPath, hostname, displayNum, cookie, vs.uid, vs.gid); err != nil { + return err + } + vs.cookieHex = hexStr + vs.authFile = authPath + return nil +} + +// cleanupXAuth removes the Xauthority file written by prepareXAuth. +func (vs *VirtualSession) cleanupXAuth() { + if vs.authFile == "" { + return + } + if err := os.Remove(vs.authFile); err != nil && !os.IsNotExist(err) { + vs.log.Debugf("remove xauth: %v", err) + } + vs.authFile = "" + vs.cookieHex = "" } // detectDesktopSession discovers available desktop sessions from the standard @@ -667,10 +731,37 @@ type sessionManager struct { } func newSessionManager(logger *log.Entry) *sessionManager { - return &sessionManager{ + sm := &sessionManager{ sessions: make(map[string]*VirtualSession), log: logger, } + sm.sweepStaleXAuth() + return sm +} + +// sweepStaleXAuth removes Xauthority files left over from a previous daemon +// instance whose X servers are no longer running. +func (sm *sessionManager) sweepStaleXAuth() { + if configs.RuntimeDir == "" { + return + } + dir := filepath.Join(configs.RuntimeDir, vncXAuthSubdir) + entries, err := os.ReadDir(dir) + if err != nil { + if !os.IsNotExist(err) { + sm.log.Debugf("scan stale xauth dir: %v", err) + } + return + } + for _, e := range entries { + if e.IsDir() { + continue + } + p := filepath.Join(dir, e.Name()) + if err := os.Remove(p); err != nil { + sm.log.Debugf("remove stale xauth %s: %v", p, err) + } + } } // GetOrCreate returns an existing virtual session or creates a new one with diff --git a/client/vnc/server/xauth_linux.go b/client/vnc/server/xauth_linux.go new file mode 100644 index 00000000000..a8fbae884c5 --- /dev/null +++ b/client/vnc/server/xauth_linux.go @@ -0,0 +1,124 @@ +//go:build unix && !darwin && !ios && !android + +package server + +import ( + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "github.com/jezek/xgb" + + "github.com/netbirdio/netbird/client/configs" +) + +// xauthFamilyLocal is the Xauth.h family value for AF_UNIX connections. +const ( + xauthFamilyLocal uint16 = 256 + xauthMITMagic = "MIT-MAGIC-COOKIE-1" +) + +// generateXAuthCookie returns a fresh 16-byte MIT-MAGIC-COOKIE-1 and its hex form. +func generateXAuthCookie() (cookie []byte, hexStr string, err error) { + cookie = make([]byte, 16) + if _, err := rand.Read(cookie); err != nil { + return nil, "", fmt.Errorf("read random cookie: %w", err) + } + return cookie, hex.EncodeToString(cookie), nil +} + +// writeXAuthFile writes a single MIT-MAGIC-COOKIE-1 entry in the binary +// Xauthority format, chowned to uid/gid and mode 0600. +func writeXAuthFile(path, hostname, display string, cookie []byte, uid, gid uint32) error { + if len(cookie) != 16 { + return fmt.Errorf("cookie must be 16 bytes") + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0711); err != nil { + return fmt.Errorf("mkdir xauth parent: %w", err) + } + // Ensure every component the daemon owns is traversable so the target + // user's desktop process can reach its file. The leaf file is still + // mode 0600 chowned to the user, and 0711 hides directory listings + // from non-owners. + if err := ensureTraversable(dir); err != nil { + return fmt.Errorf("relax xauth parent perms: %w", err) + } + + var buf []byte + appendField := func(b []byte) { + var l [2]byte + binary.BigEndian.PutUint16(l[:], uint16(len(b))) + buf = append(buf, l[:]...) + buf = append(buf, b...) + } + var fam [2]byte + binary.BigEndian.PutUint16(fam[:], xauthFamilyLocal) + buf = append(buf, fam[:]...) + appendField([]byte(hostname)) + appendField([]byte(display)) + appendField([]byte(xauthMITMagic)) + appendField(cookie) + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, buf, 0600); err != nil { + return fmt.Errorf("write xauth tmp: %w", err) + } + if err := os.Chown(tmp, int(uid), int(gid)); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("chown xauth tmp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename xauth: %w", err) + } + return nil +} + +// ensureTraversable walks up from dir to configs.RuntimeDir (inclusive) and +// sets mode 0711 on each component. Stops once it leaves the runtime dir so +// it never touches /var/run or /run. +func ensureTraversable(dir string) error { + root := filepath.Clean(configs.RuntimeDir) + if root == "" { + return nil + } + cur := filepath.Clean(dir) + for { + if err := os.Chmod(cur, 0711); err != nil { + return fmt.Errorf("chmod %s: %w", cur, err) + } + if cur == root { + return nil + } + parent := filepath.Dir(cur) + if parent == cur || !strings.HasPrefix(cur, root+string(os.PathSeparator)) { + return nil + } + cur = parent + } +} + +// dialXUnixWithCookie opens an xgb connection to display over AF_UNIX, +// authenticating with the supplied hex cookie instead of XAUTHORITY env. +func dialXUnixWithCookie(display, cookieHex string) (*xgb.Conn, error) { + if len(display) < 2 || display[0] != ':' { + return nil, fmt.Errorf("invalid X display %q", display) + } + sock := fmt.Sprintf("%s/X%s", x11SocketDir, display[1:]) + nc, err := net.Dial("unix", sock) + if err != nil { + return nil, fmt.Errorf("dial X socket %s: %w", sock, err) + } + conn, err := xgb.NewConnNetWithCookieHex(nc, cookieHex) + if err != nil { + _ = nc.Close() + return nil, fmt.Errorf("xgb auth on %s: %w", display, err) + } + return conn, nil +} From 3bcacffd2c683d836d4d863cee8fd2d1aa4fb7b3 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 25 May 2026 14:10:01 +0200 Subject: [PATCH 088/151] Rename xauth_linux.go to xauth_x11.go so FreeBSD picks it up --- client/vnc/server/{xauth_linux.go => xauth_x11.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/vnc/server/{xauth_linux.go => xauth_x11.go} (100%) diff --git a/client/vnc/server/xauth_linux.go b/client/vnc/server/xauth_x11.go similarity index 100% rename from client/vnc/server/xauth_linux.go rename to client/vnc/server/xauth_x11.go From 6cd5d6084f4db54e013d71bd10f8ecbecc86ccdf Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 25 May 2026 15:09:28 +0200 Subject: [PATCH 089/151] Split prepareAgentSocketDir to reduce cognitive complexity --- client/vnc/server/agent_darwin.go | 87 +++++++++++++++++++------------ 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index 0d6b371b5be..a7db3202b54 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -143,42 +143,12 @@ func (m *darwinAgentManager) Resolve(ctx context.Context) (string, string, uint3 // directory) so a low-priv user cannot redirect the chown that follows. func prepareAgentSocketDir(uid uint32) (string, error) { parent := configs.RuntimeDir - if parent == "" { - return "", fmt.Errorf("no runtime directory configured for this platform") - } - if err := os.MkdirAll(parent, 0o755); err != nil { - return "", fmt.Errorf("mkdir %s: %w", parent, err) - } - pInfo, err := os.Lstat(parent) - if err != nil { - return "", fmt.Errorf("lstat %s: %w", parent, err) + if err := ensureAgentSocketParent(parent); err != nil { + return "", err } - if pInfo.Mode()&os.ModeSymlink != 0 { - return "", fmt.Errorf("%s is a symlink", parent) - } - if st, ok := pInfo.Sys().(*syscall.Stat_t); ok && st.Uid != 0 { - return "", fmt.Errorf("%s not owned by root (uid=%d)", parent, st.Uid) - } - subdir := fmt.Sprintf("%s/vnc-%d", parent, uid) - // If a leftover entry exists, refuse it unless it's a real dir owned - // by the right uid with strict perms: otherwise remove and recreate - // from scratch under our control. Using os.Lstat (not Stat) so a - // symlink is detected and torn down. - if info, err := os.Lstat(subdir); err == nil { - bad := false - if info.Mode()&os.ModeSymlink != 0 { - bad = true - } else if !info.IsDir() { - bad = true - } else if st, ok := info.Sys().(*syscall.Stat_t); !ok || st.Uid != uid || info.Mode().Perm() != 0o700 { - bad = true - } - if bad { - if err := os.RemoveAll(subdir); err != nil { - return "", fmt.Errorf("remove stale %s: %w", subdir, err) - } - } + if err := purgeStaleAgentSubdir(subdir, uid); err != nil { + return "", err } if err := os.Mkdir(subdir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { return "", fmt.Errorf("mkdir %s: %w", subdir, err) @@ -192,6 +162,55 @@ func prepareAgentSocketDir(uid uint32) (string, error) { return subdir, nil } +// ensureAgentSocketParent verifies the runtime parent dir exists, is not a +// symlink, and is owned by root. +func ensureAgentSocketParent(parent string) error { + if parent == "" { + return fmt.Errorf("no runtime directory configured for this platform") + } + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", parent, err) + } + info, err := os.Lstat(parent) + if err != nil { + return fmt.Errorf("lstat %s: %w", parent, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink", parent) + } + if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid != 0 { + return fmt.Errorf("%s not owned by root (uid=%d)", parent, st.Uid) + } + return nil +} + +// purgeStaleAgentSubdir removes a leftover subdir unless it is a real dir +// owned by uid with mode 0700. Lstat (not Stat) so a symlink is detected. +func purgeStaleAgentSubdir(subdir string, uid uint32) error { + info, err := os.Lstat(subdir) + if err != nil { + return nil + } + if agentSubdirOK(info, uid) { + return nil + } + if err := os.RemoveAll(subdir); err != nil { + return fmt.Errorf("remove stale %s: %w", subdir, err) + } + return nil +} + +func agentSubdirOK(info os.FileInfo, uid uint32) bool { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false + } + return st.Uid == uid && info.Mode().Perm() == 0o700 +} + // stop terminates the spawned agent, if any. Intended for daemon shutdown. func (m *darwinAgentManager) stop() { m.mu.Lock() From 6c9465df54ad2af594aad3fa0cece0e1143abca0 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 25 May 2026 15:52:30 +0200 Subject: [PATCH 090/151] Handle Lstat error in purgeStaleAgentSubdir --- client/vnc/server/agent_darwin.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/vnc/server/agent_darwin.go b/client/vnc/server/agent_darwin.go index a7db3202b54..f7efde0fe9d 100644 --- a/client/vnc/server/agent_darwin.go +++ b/client/vnc/server/agent_darwin.go @@ -188,9 +188,12 @@ func ensureAgentSocketParent(parent string) error { // owned by uid with mode 0700. Lstat (not Stat) so a symlink is detected. func purgeStaleAgentSubdir(subdir string, uid uint32) error { info, err := os.Lstat(subdir) - if err != nil { + if errors.Is(err, os.ErrNotExist) { return nil } + if err != nil { + return fmt.Errorf("lstat %s: %w", subdir, err) + } if agentSubdirOK(info, uid) { return nil } From 144dfbc12c09333bb0c912c4678e2f518362a219 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 25 May 2026 17:02:28 +0200 Subject: [PATCH 091/151] Capture listener locally in accept loops to avoid nil deref on Stop --- client/vnc/server/server.go | 8 +++++++- client/vnc/server/server_darwin.go | 8 +++++++- client/vnc/server/server_windows.go | 9 ++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index d14584625e2..3ed69d90952 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -682,8 +682,14 @@ func (s *Server) Stop() error { // acceptLoop handles VNC connections directly (user session mode). func (s *Server) acceptLoop() { + s.mu.Lock() + ln := s.listener + s.mu.Unlock() + if ln == nil { + return + } for { - conn, err := s.listener.Accept() + conn, err := ln.Accept() if err != nil { select { case <-s.ctx.Done(): diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 18b5bbb7b1f..593de3e799d 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -30,8 +30,14 @@ func (s *Server) serviceAcceptLoop() { log.Info("service mode, proxying connections to per-user agent over Unix socket") + s.mu.Lock() + ln := s.listener + s.mu.Unlock() + if ln == nil { + return + } for { - conn, err := s.listener.Accept() + conn, err := ln.Accept() if err != nil { select { case <-s.ctx.Done(): diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 0d13dc0457d..88ebf74f80f 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -248,8 +248,15 @@ func (s *Server) serviceAcceptLoop() { log.Info("service mode, proxying connections to agent over Unix socket") + s.mu.Lock() + ln := s.listener + s.mu.Unlock() + if ln == nil { + sm.Stop() + return + } for { - conn, err := s.listener.Accept() + conn, err := ln.Accept() if err != nil { select { case <-s.ctx.Done(): From f2c79201b314de93f5b7a8b5ab1fe36ec56c5b91 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 10 Jun 2026 10:57:50 +0200 Subject: [PATCH 092/151] Fix review findings for embedded VNC server --- client/cmd/vnc_agent_dropprivs_darwin.go | 28 ++- client/cmd/vnc_agent_dropprivs_windows.go | 6 +- client/internal/engine_ssh.go | 2 +- client/internal/engine_vnc.go | 13 +- client/ssh/proxy/proxy_test.go | 2 +- client/ssh/server/jwt_test.go | 2 +- client/ssh/server/server.go | 2 +- client/ui/approval.go | 67 +++++++- client/ui/client_ui.go | 30 +--- client/vnc/ports.go | 6 +- client/vnc/server/agent_peercred_windows.go | 28 ++- client/vnc/server/agent_windows.go | 161 +++++++++++++++--- client/vnc/server/capture_darwin.go | 22 +-- client/vnc/server/capture_fb_unix.go | 2 +- client/vnc/server/capture_x11.go | 2 +- client/vnc/server/copyrect.go | 25 +++ client/vnc/server/copyrect_test.go | 63 +++++++ client/vnc/server/cursor_x11.go | 2 +- client/vnc/server/handshake.go | 65 ++++--- client/vnc/server/input_darwin.go | 2 + client/vnc/server/input_x11.go | 2 +- client/vnc/server/metrics_conn.go | 60 ++++--- client/vnc/server/server_test.go | 8 +- client/vnc/server/server_x11.go | 2 +- client/vnc/server/session.go | 62 +++---- client/vnc/server/session_encode.go | 6 +- client/vnc/server/virtual_x11.go | 2 +- client/vnc/server/xauth_x11.go | 2 +- client/wasm/cmd/main.go | 13 +- client/wasm/internal/vnc/proxy.go | 141 +++++++++------ .../peers/temporary_access_permission_test.go | 7 +- management/server/peer/peer.go | 1 - management/server/store/sql_store.go | 12 +- management/server/types/account.go | 3 +- .../server/types/networkmap_components.go | 21 ++- .../server/types/policy_authorized_users.go | 2 +- .../policy_authorized_users_security_test.go | 71 +++++++- shared/sessionauth/auth.go | 14 +- util/capture/text.go | 24 +-- 39 files changed, 694 insertions(+), 289 deletions(-) diff --git a/client/cmd/vnc_agent_dropprivs_darwin.go b/client/cmd/vnc_agent_dropprivs_darwin.go index 2e0f080da0a..d61e33747be 100644 --- a/client/cmd/vnc_agent_dropprivs_darwin.go +++ b/client/cmd/vnc_agent_dropprivs_darwin.go @@ -5,6 +5,8 @@ package cmd import ( "fmt" "os" + "os/user" + "strconv" "syscall" ) @@ -31,14 +33,21 @@ func dropAgentPrivileges(targetUID uint32) error { if cur != 0 { return fmt.Errorf("agent uid %d does not match expected %d and we lack root to fix it", cur, targetUID) } + // Resolve the target user's real primary group rather than reusing + // targetUID as the gid: a user's primary group on macOS is typically + // staff(20), not gid==uid. Fail closed if the lookup fails. + targetGID, err := primaryGroupID(targetUID) + if err != nil { + return err + } // Drop supplementary groups first: setgid alone doesn't touch the // auxiliary group list, leaving root's groups attached would let the // dropped process write to root-only group-writable files. if err := syscall.Setgroups([]int{}); err != nil { return fmt.Errorf("setgroups([]): %w", err) } - if err := syscall.Setgid(int(targetUID)); err != nil { - return fmt.Errorf("setgid(%d): %w", targetUID, err) + if err := syscall.Setgid(targetGID); err != nil { + return fmt.Errorf("setgid(%d): %w", targetGID, err) } if err := syscall.Setuid(int(targetUID)); err != nil { return fmt.Errorf("setuid(%d): %w", targetUID, err) @@ -48,3 +57,18 @@ func dropAgentPrivileges(targetUID uint32) error { } return nil } + +// primaryGroupID resolves the real primary group id of the user with the +// given uid. Fails closed: a lookup or parse error returns an error so the +// caller never falls back to using uid as the gid. +func primaryGroupID(targetUID uint32) (int, error) { + u, err := user.LookupId(strconv.Itoa(int(targetUID))) + if err != nil { + return 0, fmt.Errorf("look up uid %d: %w", targetUID, err) + } + gid, err := strconv.Atoi(u.Gid) + if err != nil { + return 0, fmt.Errorf("parse gid %q for uid %d: %w", u.Gid, targetUID, err) + } + return gid, nil +} diff --git a/client/cmd/vnc_agent_dropprivs_windows.go b/client/cmd/vnc_agent_dropprivs_windows.go index 7e63537408f..7803be77c71 100644 --- a/client/cmd/vnc_agent_dropprivs_windows.go +++ b/client/cmd/vnc_agent_dropprivs_windows.go @@ -6,9 +6,9 @@ package cmd // both run as SYSTEM (the daemon spawns the agent into the interactive // session via CreateProcessAsUser with an impersonation token, but the // resulting process still runs under SYSTEM, not under the user's -// account). The Windows path relies on the C:\Windows\Temp socket -// location (admin/SYSTEM-write-only) and the per-spawn token for -// integrity instead. +// account). The Windows path relies on the DACL-restricted socket +// directory, the unpredictable per-spawn socket name, the listen-readiness +// gate, and the per-spawn token for integrity instead. func dropAgentPrivileges(_ uint32) error { return nil } diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index 17296d6d719..0dbd4a16ffb 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -12,10 +12,10 @@ import ( firewallManager "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/netstack" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" - sshauth "github.com/netbirdio/netbird/shared/sessionauth" sshconfig "github.com/netbirdio/netbird/client/ssh/config" sshserver "github.com/netbirdio/netbird/client/ssh/server" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 9cebd014e75..7aff50f90da 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -17,12 +17,11 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/vnc" vncserver "github.com/netbirdio/netbird/client/vnc/server" - sshauth "github.com/netbirdio/netbird/shared/sessionauth" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) - type vncServer interface { Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error Stop() error @@ -188,13 +187,13 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { } sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys())) - for _, e := range vncAuth.GetSessionPubKeys() { - pub := e.GetPubKey() + for _, pk := range vncAuth.GetSessionPubKeys() { + pub := pk.GetPubKey() if len(pub) != 32 { log.Warnf("VNC session pubkey wrong length %d", len(pub)) continue } - hash := e.GetUserIdHash() + hash := pk.GetUserIdHash() if len(hash) != 16 { log.Warnf("VNC session user id hash wrong length %d", len(hash)) continue @@ -202,7 +201,7 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{ PubKey: pub, UserIDHash: sshuserhash.UserIDHash(hash), - DisplayName: e.GetDisplayName(), + DisplayName: pk.GetDisplayName(), }) } @@ -236,7 +235,7 @@ func (e *Engine) stopVNCServer() error { log.Warnf("cleanup VNC port redirection: %v", err) } - if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + if e.wgInterface != nil && e.wgInterface.GetNet() != nil { if registrar, ok := e.firewall.(interface { UnregisterNetstackService(protocol nftypes.Protocol, port uint16) }); ok { diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go index 02cd1d58c91..8c2b67a4f8e 100644 --- a/client/ssh/proxy/proxy_test.go +++ b/client/ssh/proxy/proxy_test.go @@ -28,10 +28,10 @@ import ( "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" - sshauth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/client/ssh/server" "github.com/netbirdio/netbird/client/ssh/testutil" nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) diff --git a/client/ssh/server/jwt_test.go b/client/ssh/server/jwt_test.go index def3658c9e4..66d6b9e0475 100644 --- a/client/ssh/server/jwt_test.go +++ b/client/ssh/server/jwt_test.go @@ -23,11 +23,11 @@ import ( "github.com/stretchr/testify/require" nbssh "github.com/netbirdio/netbird/client/ssh" - sshauth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/client/ssh/client" "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/client/ssh/testutil" nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 499743c66af..20ed55986eb 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -23,10 +23,10 @@ import ( "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" - sshauth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/auth/jwt" + sshauth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/util/netrelay" "github.com/netbirdio/netbird/version" ) diff --git a/client/ui/approval.go b/client/ui/approval.go index 7c99585f9fc..0c2dd1f7ed7 100644 --- a/client/ui/approval.go +++ b/client/ui/approval.go @@ -4,7 +4,10 @@ package main import ( "context" + "errors" "fmt" + "os" + "os/exec" "strings" "time" @@ -17,6 +20,18 @@ import ( "github.com/netbirdio/netbird/client/proto" ) +// Approval metadata that is remote-peer or dashboard controlled is passed to +// the forked netbird-ui via environment variables rather than argv, so it is +// not exposed to other local users through ps. +const ( + envApprovalInitiator = "NB_APPROVAL_INITIATOR" + envApprovalPeerName = "NB_APPROVAL_PEER_NAME" + envApprovalSourceIP = "NB_APPROVAL_SOURCE_IP" + envApprovalUsername = "NB_APPROVAL_USERNAME" + envApprovalKeyFingerprint = "NB_APPROVAL_KEY_FINGERPRINT" + envApprovalSubject = "NB_APPROVAL_SUBJECT" +) + // handleApprovalEvent forks a netbird-ui child process to render the // dialog on its own fyne main loop. Top-level windows opened from a // background goroutine of the tray process don't render reliably on @@ -31,18 +46,56 @@ func (s *serviceClient) handleApprovalEvent(ev *proto.SystemEvent) { log.Warnf("approval event missing request_id: %v", ev.Metadata) return } + + // Only the request id, kind, and deadline stay on argv: they are + // daemon-issued and non-sensitive. The remote-influenced fields go + // through the child's environment. args := []string{ "--approval-request-id=" + requestID, "--approval-kind=" + ev.Metadata["kind"], - "--approval-initiator=" + ev.Metadata["initiator"], - "--approval-peer-name=" + ev.Metadata["peer_name"], - "--approval-source-ip=" + ev.Metadata["source_ip"], - "--approval-username=" + ev.Metadata["username"], "--approval-expires-at=" + ev.Metadata["expires_at"], - "--approval-key-fingerprint=" + ev.Metadata["peer_pubkey"], - "--approval-subject=" + ev.UserMessage, } - go s.eventHandler.runSelfCommand(s.ctx, "approval", args...) + env := append(os.Environ(), + envApprovalInitiator+"="+ev.Metadata["initiator"], + envApprovalPeerName+"="+ev.Metadata["peer_name"], + envApprovalSourceIP+"="+ev.Metadata["source_ip"], + envApprovalUsername+"="+ev.Metadata["username"], + envApprovalKeyFingerprint+"="+ev.Metadata["peer_pubkey"], + envApprovalSubject+"="+ev.UserMessage, + ) + go s.runApprovalCommand(s.ctx, env, args) +} + +// runApprovalCommand forks netbird-ui to render the approval dialog, +// inheriting the parent environment plus the approval-specific variables. It +// mirrors runSelfCommand but sets cmd.Env so the sensitive metadata never +// appears on the child's argv. +func (s *serviceClient) runApprovalCommand(ctx context.Context, env, args []string) { + proc, err := os.Executable() + if err != nil { + log.Errorf("get executable path: %v", err) + return + } + + cmdArgs := append([]string{"--approval=true", "--daemon-addr=" + s.addr}, args...) + cmd := exec.CommandContext(ctx, proc, cmdArgs...) + cmd.Env = env + + if out := s.attachOutput(cmd); out != nil { + defer func() { + if err := out.Close(); err != nil { + log.Errorf("close log file %s: %v", s.logFile, err) + } + }() + } + + log.Printf("running approval command: %s", cmd.String()) + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + log.Printf("approval command failed with exit code %d", exitErr.ExitCode()) + } + } } // showApprovalUI runs the dialog on the forked process's fyne main loop diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index d030937a69a..164bbcf8a9e 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -101,13 +101,13 @@ func main() { approvalRequest: approvalRequest{ requestID: flags.approvalRequestID, kind: flags.approvalKind, - initiator: flags.approvalInitiator, - peerName: flags.approvalPeerName, - sourceIP: flags.approvalSourceIP, - username: flags.approvalUsername, - subject: flags.approvalSubject, + initiator: os.Getenv(envApprovalInitiator), + peerName: os.Getenv(envApprovalPeerName), + sourceIP: os.Getenv(envApprovalSourceIP), + username: os.Getenv(envApprovalUsername), + subject: os.Getenv(envApprovalSubject), expiresAt: flags.approvalExpiresAt, - keyFingerprint: flags.approvalKeyFingerprint, + keyFingerprint: os.Getenv(envApprovalKeyFingerprint), }, }) @@ -154,15 +154,9 @@ type cliFlags struct { showUpdateVersion string showApproval bool - approvalRequestID string - approvalKind string - approvalInitiator string - approvalPeerName string - approvalSourceIP string - approvalUsername string - approvalSubject string - approvalExpiresAt string - approvalKeyFingerprint string + approvalRequestID string + approvalKind string + approvalExpiresAt string } // parseFlags reads and returns all needed command-line flags. @@ -187,13 +181,7 @@ func parseFlags() *cliFlags { flag.BoolVar(&flags.showApproval, "approval", false, "show inbound-connection approval prompt window") flag.StringVar(&flags.approvalRequestID, "approval-request-id", "", "approval prompt: daemon-issued request id") flag.StringVar(&flags.approvalKind, "approval-kind", "", "approval prompt: subsystem kind (vnc, ssh, ...)") - flag.StringVar(&flags.approvalInitiator, "approval-initiator", "", "approval prompt: display name of the user who initiated the connection") - flag.StringVar(&flags.approvalPeerName, "approval-peer-name", "", "approval prompt: remote peer FQDN") - flag.StringVar(&flags.approvalSourceIP, "approval-source-ip", "", "approval prompt: remote source IP") - flag.StringVar(&flags.approvalUsername, "approval-username", "", "approval prompt: requested OS username") - flag.StringVar(&flags.approvalSubject, "approval-subject", "", "approval prompt: human-readable subject line") flag.StringVar(&flags.approvalExpiresAt, "approval-expires-at", "", "approval prompt: RFC3339 deadline at which the daemon auto-denies") - flag.StringVar(&flags.approvalKeyFingerprint, "approval-key-fingerprint", "", "approval prompt: hex-encoded Noise static pubkey of the connecting client") flag.Parse() return &flags } diff --git a/client/vnc/ports.go b/client/vnc/ports.go index 5e80810c1ec..33c9bc2cf51 100644 --- a/client/vnc/ports.go +++ b/client/vnc/ports.go @@ -11,9 +11,9 @@ package vnc // sockets; kept here so packet captures from older builds still get // tagged, and so any future on-wire agent variant has a reserved port. const ( - ExternalPort uint16 = 5900 - InternalPort uint16 = 25900 - AgentLegacyPort uint16 = 15900 + ExternalPort uint16 = 5900 + InternalPort uint16 = 25900 + AgentLegacyPort uint16 = 15900 ) // WellKnownPorts is the unordered set of ports a packet capture should diff --git a/client/vnc/server/agent_peercred_windows.go b/client/vnc/server/agent_peercred_windows.go index 6f2d3bc5c92..d6d4f82d6a5 100644 --- a/client/vnc/server/agent_peercred_windows.go +++ b/client/vnc/server/agent_peercred_windows.go @@ -6,14 +6,26 @@ import ( "net" ) -// validateAgentPeer is a best-effort no-op on Windows: AF_UNIX sockets on -// Windows do not expose SO_PEERCRED equivalents, and both the daemon and -// the spawned agent run as SYSTEM in distinct sessions. The remaining -// trust comes from the location of the socket file (under -// C:\Windows\Temp, writable only by SYSTEM/Administrators) and from the -// per-spawn auth token preamble that follows this call. Documented as a -// known gap; a future hardening pass could interrogate the connected -// pipe's PID via process-token APIs. +// validateAgentPeer is a documented no-op on Windows. AF_UNIX on Windows +// exposes no SO_PEERCRED equivalent and no supported API to recover the +// peer process from an accepted AF_UNIX connection, so the daemon cannot +// match the connected peer against the agent PID it spawned the way the +// darwin path does via LOCAL_PEERCRED. The Windows trust model therefore +// rests on three other measures, none of which assume the socket path is +// secret: +// +// - the socket lives in a dedicated directory (agentSocketDir) created +// with a DACL granting only SYSTEM and Administrators, so an +// unprivileged local user cannot create or squat a socket there; +// - each spawn uses a cryptographically random socket name, so the path +// is unguessable before the agent binds it; +// - the daemon publishes the path only after confirming the spawned +// agent is listening (see waitForAgentListening), and gates every +// connection on the per-spawn auth-token preamble that follows this +// call. +// +// If a future Windows release exposes peer-PID retrieval for AF_UNIX, +// this function should verify the peer against the spawned agent PID. func validateAgentPeer(_ net.Conn, _ uint32) error { return nil } diff --git a/client/vnc/server/agent_windows.go b/client/vnc/server/agent_windows.go index 74fa7e0dcc7..b726bcc378f 100644 --- a/client/vnc/server/agent_windows.go +++ b/client/vnc/server/agent_windows.go @@ -4,10 +4,14 @@ package server import ( "context" + crand "crypto/rand" "encoding/binary" + "encoding/hex" "errors" "fmt" + "net" "os" + "path/filepath" "runtime" "sync" "time" @@ -362,10 +366,33 @@ type sessionManager struct { jobHandle windows.Handle } -// agentSocketPathFmt parameterizes the per-session agent socket path by -// the Windows session id. C:\Windows\Temp is writable to both the daemon -// (SYSTEM) and the spawned agent (SYSTEM token impersonating the session). -const agentSocketPathFmt = `C:\Windows\Temp\netbird-vnc-%d.sock` +const ( + // agentSocketDir is a dedicated subdirectory under C:\Windows\Temp that + // the daemon creates with a restrictive DACL (SYSTEM + Administrators + // only). The default ACL on C:\Windows\Temp grants BUILTIN\Users + // create-file rights, so the agent socket must not live directly there: + // an unprivileged local user could pre-create a predictable path and + // intercept the daemon→agent stream. Both the daemon and the agent run + // as SYSTEM, so a SYSTEM-write-only directory is sufficient. + agentSocketDir = `C:\Windows\Temp\netbird-vnc` + + // agentSocketDirSDDL grants full access to Local System (SY) and the + // Builtin Administrators group (BA) only, with the DACL protected + // (P) from inheritance so the parent's BUILTIN\Users grant does not + // flow in. AI is omitted; PAI marks the DACL protected and auto- + // inherited entries cleared. + agentSocketDirSDDL = "D:PAI(A;;FA;;;SY)(A;;FA;;;BA)" + + // agentSocketRandomLen is the number of random bytes mixed into each + // per-spawn socket name so the path is unguessable before the agent + // owns it. + agentSocketRandomLen = 16 + + // agentReadyTimeout bounds how long the daemon waits for the freshly + // spawned agent to bind and accept on its socket before treating the + // spawn as failed. + agentReadyTimeout = 5 * time.Second +) func newSessionManager() *sessionManager { m := &sessionManager{sessionID: ^uint32(0), done: make(chan struct{})} @@ -427,11 +454,14 @@ func createKillOnCloseJob() (windows.Handle, error) { // Resolve returns the current agent socket path, shared token, and the // uid the agent runs under (0 on Windows since the agent runs as -// SYSTEM in the interactive session; validateAgentPeer is a no-op -// there). When no agent is spawned yet (initial boot, between session -// switches, or permanently disabled when SE_TCB_NAME is missing) it -// surfaces a distinct error so the daemon can reject the connection -// with a meaningful message instead of timing out the proxy dial. +// SYSTEM in the interactive session; see validateAgentPeer for the +// Windows trust model). The path is only published after the spawned +// agent is confirmed listening, so a caller never receives a socket a +// squatter could be holding. When no agent is spawned yet (initial +// boot, between session switches, or permanently disabled when +// SE_TCB_NAME is missing) it surfaces a distinct error so the daemon +// can reject the connection with a meaningful message instead of timing +// out the proxy dial. func (m *sessionManager) Resolve(_ context.Context) (string, string, uint32, error) { m.mu.Lock() defer m.mu.Unlock() @@ -547,13 +577,21 @@ func (m *sessionManager) maybeSpawnAgent(sid uint32) bool { if m.agentProc != 0 || sid == 0xFFFFFFFF || !time.Now().After(m.nextSpawnAt) { return true } - // Reap any orphan still holding the agent port from a previous - // service instance, only on our very first spawn. Once we own - // an agent, we manage its lifecycle ourselves and never need to - // kill an unknown listener; if a kill+respawn races on port - // release, the spawn-failure backoff handles it without forcing - // a synchronous wait or duplicate kill. - socketPath := fmt.Sprintf(agentSocketPathFmt, sid) + + if err := ensureAgentSocketDir(); err != nil { + log.Warnf("prepare agent socket dir: %v", err) + m.nextSpawnAt = time.Now().Add(5 * time.Second) + return true + } + + // The leaf name carries a cryptographically random component so a local + // user cannot pre-create the path at a guessable location. The session + // id is kept for diagnostics only; security does not rely on it. + socketPath, err := newAgentSocketPath(sid) + if err != nil { + log.Warnf("generate agent socket path: %v", err) + return true + } // Covers a previous-run crash that escaped Job Object kill-on-close. if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { log.Debugf("clear stale agent socket %s: %v", socketPath, err) @@ -563,12 +601,8 @@ func (m *sessionManager) maybeSpawnAgent(sid uint32) bool { log.Warnf("generate agent auth token: %v", err) return true } - m.authToken = token - m.socketPath = socketPath - h, err := spawnAgentInSession(sid, socketPath, m.authToken, m.jobHandle) + h, err := spawnAgentInSession(sid, socketPath, token, m.jobHandle) if err != nil { - m.authToken = "" - m.socketPath = "" if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) { // SE_TCB_NAME (token-impersonation across sessions) is only // granted to SYSTEM. Without it spawnAgent will fail every 2 @@ -579,12 +613,97 @@ func (m *sessionManager) maybeSpawnAgent(sid uint32) bool { log.Warnf("spawn agent in session %d: %v", sid, err) return true } + + // Gate on listen-readiness before publishing the path: do not hand a + // caller a socket the agent has not bound yet. On timeout, fail closed + // by killing the agent and leaving socketPath/authToken unset so + // Resolve keeps returning errAgentNotReady. + if err := waitForAgentListening(socketPath, agentReadyTimeout); err != nil { + log.Warnf("agent in session %d did not start listening: %v", sid, err) + _ = windows.TerminateProcess(h, 1) + _ = windows.CloseHandle(h) + if rmErr := os.Remove(socketPath); rmErr != nil && !os.IsNotExist(rmErr) { + log.Debugf("clear unready agent socket %s: %v", socketPath, rmErr) + } + m.scheduleNextSpawn(0, 0) + return true + } + + m.authToken = token + m.socketPath = socketPath m.agentProc = h m.agentStartedAt = time.Now() m.everSpawned = true return true } +// ensureAgentSocketDir creates the dedicated socket directory with a +// restrictive DACL (SYSTEM + Administrators only). A pre-existing directory +// is torn down and recreated rather than reused: it may have been created by +// an unprivileged user with a permissive ACL, and it only ever holds our +// transient sockets, so removing it loses nothing. Fails closed: returns an +// error if the directory cannot be created with the intended security. +func ensureAgentSocketDir() error { + sd, err := windows.SecurityDescriptorFromString(agentSocketDirSDDL) + if err != nil { + return fmt.Errorf("parse socket dir SDDL: %w", err) + } + var sa windows.SecurityAttributes + sa.Length = uint32(unsafe.Sizeof(sa)) + sa.SecurityDescriptor = sd + + dirW, err := windows.UTF16PtrFromString(agentSocketDir) + if err != nil { + return fmt.Errorf("encode socket dir path: %w", err) + } + err = windows.CreateDirectory(dirW, &sa) + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + if rmErr := os.RemoveAll(agentSocketDir); rmErr != nil { + return fmt.Errorf("remove pre-existing socket dir %s: %w", agentSocketDir, rmErr) + } + err = windows.CreateDirectory(dirW, &sa) + } + if err != nil { + return fmt.Errorf("create socket dir %s: %w", agentSocketDir, err) + } + return nil +} + +// newAgentSocketPath returns a per-spawn socket path inside the secured +// socket directory. The leaf name mixes a cryptographically random component +// with the session id (for diagnostics) so the path is unguessable before the +// agent binds it. +func newAgentSocketPath(sessionID uint32) (string, error) { + b := make([]byte, agentSocketRandomLen) + if _, err := crand.Read(b); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + name := fmt.Sprintf("netbird-vnc-%d-%s.sock", sessionID, hex.EncodeToString(b)) + return filepath.Join(agentSocketDir, name), nil +} + +// waitForAgentListening dials the agent's Unix socket until it answers or the +// timeout elapses. Mirrors the darwin readiness gate so the daemon never +// exposes a socket path before the legitimate agent owns it. +func waitForAgentListening(socketPath string, wait time.Duration) error { + var d net.Dialer + deadline := time.Now().Add(wait) + var lastErr error + for time.Now().Before(deadline) { + c, err := d.Dial("unix", socketPath) + if err == nil { + _ = c.Close() + return nil + } + lastErr = err + time.Sleep(100 * time.Millisecond) + } + if lastErr == nil { + lastErr = fmt.Errorf("timeout") + } + return fmt.Errorf("dial %s: %w", socketPath, lastErr) +} + func (m *sessionManager) killAgent() { if m.agentProc == 0 { return diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index 6b975ed87bf..d3c31a1a1c6 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -204,10 +204,11 @@ func (c *CGCapturer) Width() int { return c.w } // Height returns the screen height. func (c *CGCapturer) Height() int { return c.h } -// Capture returns the current screen as an RGBA image. // CaptureInto writes a fresh frame directly into dst, skipping the -// per-frame image.RGBA allocation that Capture() does. Returns -// errFrameUnchanged when the screen hash matches the prior call. +// per-frame image.RGBA allocation that Capture() does. It always fills +// dst: the capturer is shared across all sessions, so dedup here would +// starve every consumer but the first one to poll after a change. +// Per-session prevFrame diffing in the session layer handles no-op frames. func (c *CGCapturer) CaptureInto(dst *image.RGBA) error { cgImage := cgDisplayCreateImage(c.displayID) if cgImage == 0 { @@ -233,12 +234,6 @@ func (c *CGCapturer) CaptureInto(dst *image.RGBA) error { return fmt.Errorf("empty image data") } src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen) - hash := maphash.Bytes(c.hashSeed, src) - if c.hasHash && hash == c.lastHash { - return errFrameUnchanged - } - c.lastHash = hash - c.hasHash = true ds := c.downscale if ds < 1 { @@ -565,14 +560,7 @@ func (p *MacPoller) CaptureInto(dst *image.RGBA) error { if err := p.ensureCapturerLocked(); err != nil { return err } - err := p.capturer.CaptureInto(dst) - if errors.Is(err, errFrameUnchanged) { - // Caller (session) treats this as "no change"; the dst buffer - // keeps its prior contents from the previous capture cycle so - // the diff stays meaningful. - return err - } - if err != nil { + if err := p.capturer.CaptureInto(dst); err != nil { p.capturer = nil return fmt.Errorf("macos capture: %w", err) } diff --git a/client/vnc/server/capture_fb_unix.go b/client/vnc/server/capture_fb_unix.go index 0c2a0dac0cc..63351dcdb3e 100644 --- a/client/vnc/server/capture_fb_unix.go +++ b/client/vnc/server/capture_fb_unix.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build (linux && !android) || freebsd package server diff --git a/client/vnc/server/capture_x11.go b/client/vnc/server/capture_x11.go index bdfaf7c4286..cb28201d7fd 100644 --- a/client/vnc/server/capture_x11.go +++ b/client/vnc/server/capture_x11.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build (linux && !android) || freebsd package server diff --git a/client/vnc/server/copyrect.go b/client/vnc/server/copyrect.go index ce75e41fe0b..4e73e2a543a 100644 --- a/client/vnc/server/copyrect.go +++ b/client/vnc/server/copyrect.go @@ -191,6 +191,16 @@ func (d *copyRectDetector) extractCopyRectTiles(cur *image.RGBA, dirtyTiles [][4 for _, r := range dirtyTiles { if r[2] == ts && r[3] == ts { if sx, sy, ok := d.findTileMatch(cur, r[0], r[1]); ok { + // The client applies moves sequentially against its live + // framebuffer. If this move's source overlaps the + // destination of any move already queued, that destination + // has overwritten the source pixels client-side, so the + // copy would read corrupted data. Drop it and let the tile + // fall through to normal pixel encoding instead. + if tileOverlapsPriorDst(moves, sx, sy, ts) { + remaining = append(remaining, r) + continue + } moves = append(moves, copyRectMove{ srcX: sx, srcY: sy, dstX: r[0], dstY: r[1], }) @@ -201,3 +211,18 @@ func (d *copyRectDetector) extractCopyRectTiles(cur *image.RGBA, dirtyTiles [][4 } return moves, remaining } + +// tileOverlapsPriorDst reports whether the tileSize-square source rectangle +// at (srcX, srcY) intersects the destination rectangle of any move already +// emitted. All move rectangles are ts×ts, so the test reduces to a +// per-axis distance check. +func tileOverlapsPriorDst(moves []copyRectMove, srcX, srcY, ts int) bool { + for _, m := range moves { + dx := srcX - m.dstX + dy := srcY - m.dstY + if dx > -ts && dx < ts && dy > -ts && dy < ts { + return true + } + } + return false +} diff --git a/client/vnc/server/copyrect_test.go b/client/vnc/server/copyrect_test.go index 0295e6c2605..610a04abace 100644 --- a/client/vnc/server/copyrect_test.go +++ b/client/vnc/server/copyrect_test.go @@ -83,6 +83,69 @@ func TestCopyRectDetector_DetectsVerticalScroll(t *testing.T) { } } +// rectsOverlap reports whether two ts×ts tiles at the given origins overlap. +func tilesOverlap(ax, ay, bx, by, ts int) bool { + return ax < bx+ts && bx < ax+ts && ay < by+ts && by < ay+ts +} + +// TestCopyRectDetector_DownwardScrollNoOverlap exercises a downward scroll, +// where each move's source is the destination of the move one row above it. +// Emitting all of them in order would corrupt the client framebuffer because +// the earlier move overwrites the source pixels the later move reads. The +// detector must drop any move whose source overlaps a prior move's +// destination and route that tile to pixel encoding instead. +func TestCopyRectDetector_DownwardScrollNoOverlap(t *testing.T) { + const w, h = 256, 192 // 4×3 tiles at 64px + const ts = 64 + + prev := image.NewRGBA(image.Rect(0, 0, w, h)) + cur := image.NewRGBA(image.Rect(0, 0, w, h)) + + // prev: 12 tiles each with a unique colour. + for ty := 0; ty < 3; ty++ { + for tx := 0; tx < 4; tx++ { + fillTile(prev, tx*ts, ty*ts, ts, byte(tx*40), byte(ty*60), 0x80) + } + } + // cur: scroll downward by one row. Rows 1 and 2 are copied from prev + // rows 0 and 1; the top row is new content. + for ty := 1; ty < 3; ty++ { + for tx := 0; tx < 4; tx++ { + copyTile(cur, prev, tx*ts, (ty-1)*ts, tx*ts, ty*ts, ts) + } + } + for tx := 0; tx < 4; tx++ { + fillTile(cur, tx*ts, 0, ts, 0xff, 0xff, 0xff) + } + + d := newCopyRectDetector(ts) + d.rebuild(prev, w, h) + + tiles := diffTiles(prev, cur, w, h, ts) + wantTiles := len(tiles) + moves, remaining := d.extractCopyRectTiles(cur, tiles) + + // No move's source may overlap an earlier move's destination. + for i, m := range moves { + for _, prior := range moves[:i] { + if tilesOverlap(m.srcX, m.srcY, prior.dstX, prior.dstY, ts) { + t.Fatalf("move %d src (%d,%d) overlaps prior dst (%d,%d)", + i, m.srcX, m.srcY, prior.dstX, prior.dstY) + } + } + } + + // The dropped row-2 moves must fall through to pixel encoding rather than + // being silently skipped, so the region still updates correctly. + if len(moves)+len(remaining) != wantTiles { + t.Fatalf("moves(%d)+remaining(%d) != dirty tiles(%d): a tile was lost", + len(moves), len(remaining), wantTiles) + } + if len(moves) != 4 { + t.Fatalf("moves: want 4 (top scrolled row only), got %d", len(moves)) + } +} + func TestCopyRectDetector_RejectsSelfMatch(t *testing.T) { const w, h = 128, 128 const ts = 64 diff --git a/client/vnc/server/cursor_x11.go b/client/vnc/server/cursor_x11.go index a00ddf55c91..e14ff856bfd 100644 --- a/client/vnc/server/cursor_x11.go +++ b/client/vnc/server/cursor_x11.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build (linux && !android) || freebsd package server diff --git a/client/vnc/server/handshake.go b/client/vnc/server/handshake.go index ddbc475fca3..ae2460e2fae 100644 --- a/client/vnc/server/handshake.go +++ b/client/vnc/server/handshake.go @@ -3,7 +3,6 @@ package server import ( - "bufio" "bytes" "crypto/subtle" "encoding/binary" @@ -119,21 +118,35 @@ func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) username = string(buf) } - br := bufio.NewReader(conn) - clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br, mode, username) + // Read the 4-byte magic candidate directly off the wire instead of + // buffering ahead with a bufio.Reader: the session reads the raw conn + // after this returns, so any bytes a bufio.Reader buffered past the + // header would be silently dropped. When the bytes aren't the v3 magic + // they are the start of the session_id field and feed straight into it. + var magicBuf [4]byte + if _, err := io.ReadFull(conn, magicBuf[:]); err != nil { + return &connectionHeader{mode: mode, username: username}, nil + } + + clientStatic, identityVerified, magicConsumed, err := s.maybeRunNoiseHandshake(conn, magicBuf, mode, username) if err != nil { return nil, err } var sessionID uint32 - var sidBuf [4]byte - if _, err := io.ReadFull(br, sidBuf[:]); err == nil { - sessionID = binary.BigEndian.Uint32(sidBuf[:]) + var width, height uint16 + if magicConsumed { + var sidBuf [4]byte + if _, err := io.ReadFull(conn, sidBuf[:]); err == nil { + sessionID = binary.BigEndian.Uint32(sidBuf[:]) + } + } else { + // No magic: the 4 bytes we already read are the session_id. + sessionID = binary.BigEndian.Uint32(magicBuf[:]) } - var width, height uint16 var geomBuf [4]byte - if _, err := io.ReadFull(br, geomBuf[:]); err == nil { + if _, err := io.ReadFull(conn, geomBuf[:]); err == nil { width = binary.BigEndian.Uint16(geomBuf[0:2]) height = binary.BigEndian.Uint16(geomBuf[2:4]) } @@ -155,18 +168,14 @@ func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) // (fail closed). headerMode and headerUsername are mixed into the Noise // prologue so the client cannot lie in the cleartext header prefix // without making its own AEAD MAC verify-fail on the responder side. -func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader, headerMode byte, headerUsername string) ([]byte, bool, error) { - peek, _ := br.Peek(len(vncIdentityMagic)) - if !bytes.Equal(peek, vncIdentityMagic) { - return nil, false, nil - } - if _, err := br.Discard(len(vncIdentityMagic)); err != nil { - return nil, false, fmt.Errorf("discard identity magic: %w", err) +func (s *Server) maybeRunNoiseHandshake(conn net.Conn, magic [4]byte, headerMode byte, headerUsername string) (clientStatic []byte, identityVerified, magicConsumed bool, err error) { + if !bytes.Equal(magic[:], vncIdentityMagic) { + return nil, false, false, nil } msg1 := make([]byte, noiseInitiatorMsgLen) - if _, err := io.ReadFull(br, msg1); err != nil { - return nil, false, fmt.Errorf("read noise msg1: %w", err) + if _, err := io.ReadFull(conn, msg1); err != nil { + return nil, false, true, fmt.Errorf("read noise msg1: %w", err) } // Agents on loopback authenticate via the agent token, not this @@ -179,11 +188,11 @@ func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader, headerM // short-circuit will see the truthful "no Noise identity proved // here" rather than a stale true. if s.disableAuth { - return nil, false, nil + return nil, false, true, nil } if len(s.identityKey) != 32 || len(s.identityPublic) != 32 { - return nil, false, errors.New("identity key not configured") + return nil, false, true, errors.New("identity key not configured") } state, err := noise.NewHandshakeState(noise.Config{ CipherSuite: vncNoiseSuite, @@ -193,27 +202,27 @@ func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader, headerM StaticKeypair: noise.DHKey{Private: s.identityKey, Public: s.identityPublic}, }) if err != nil { - return nil, false, fmt.Errorf("noise responder init: %w", err) + return nil, false, true, fmt.Errorf("noise responder init: %w", err) } if _, _, _, err := state.ReadMessage(nil, msg1); err != nil { - return nil, false, fmt.Errorf("noise read msg1: %w", err) + return nil, false, true, fmt.Errorf("noise read msg1: %w", err) } msg2, _, _, err := state.WriteMessage(nil, nil) if err != nil { - return nil, false, fmt.Errorf("noise write msg2: %w", err) + return nil, false, true, fmt.Errorf("noise write msg2: %w", err) } if len(msg2) != noiseResponderMsgLen { - return nil, false, fmt.Errorf("noise responder produced %d bytes, expected %d", len(msg2), noiseResponderMsgLen) + return nil, false, true, fmt.Errorf("noise responder produced %d bytes, expected %d", len(msg2), noiseResponderMsgLen) } if _, err := conn.Write(msg2); err != nil { - return nil, false, fmt.Errorf("write noise msg2: %w", err) + return nil, false, true, fmt.Errorf("write noise msg2: %w", err) } - clientStatic := state.PeerStatic() - if len(clientStatic) != 32 { - return nil, false, errors.New("noise peer static missing") + peerStatic := state.PeerStatic() + if len(peerStatic) != 32 { + return nil, false, true, errors.New("noise peer static missing") } - return clientStatic, true, nil + return peerStatic, true, true, nil } // verifyAgentToken validates the agent token prefix when configured and diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index b1b4e992634..76f4c3a79cb 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -281,6 +281,8 @@ func releasePreventIdleSleep() { } func ensureEventSource() uintptr { + pmMu.Lock() + defer pmMu.Unlock() if darwinEventSource != 0 { return darwinEventSource } diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index d0588470eba..a3eecb95cb5 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build (linux && !android) || freebsd package server diff --git a/client/vnc/server/metrics_conn.go b/client/vnc/server/metrics_conn.go index 75749629ad0..275822cf821 100644 --- a/client/vnc/server/metrics_conn.go +++ b/client/vnc/server/metrics_conn.go @@ -3,6 +3,7 @@ package server import ( + "encoding/binary" "net" "sync" "sync/atomic" @@ -169,26 +170,20 @@ func (m *metricsConn) BusyFraction() float64 { return m.busyFraction } -// isFBUHeader reports whether the given Write payload is the 4-byte -// FramebufferUpdate header (message type 0, padding 0, rect-count high -// byte). Rect bodies are written separately by sendDirtyAndMoves, so the -// FBU/rect boundary lines up with Write boundaries. -func isFBUHeader(p []byte) bool { - return len(p) == 4 && p[0] == serverFramebufferUpdate +// startsFBU reports whether the Write payload begins a FramebufferUpdate +// message (message type byte 0). This holds both for the standalone 4-byte +// header that sendDirtyAndMoves writes before its rect bodies and for the +// single framed Write that sendFullUpdate / sendEmptyUpdate use to emit a +// whole FBU (header plus body) at once. Either way the FBU boundary lines +// up with this Write boundary. +func startsFBU(p []byte) bool { + return len(p) >= 1 && p[0] == serverFramebufferUpdate } func (m *metricsConn) Write(p []byte) (int, error) { - if isFBUHeader(p) { - if b := m.fbuBytes.Swap(0); b > 0 { - if b > m.maxFBUBytes.Load() { - m.maxFBUBytes.Store(b) - } - } - if r := m.fbuRects.Swap(0); r > 0 { - if r > m.maxFBURects.Load() { - m.maxFBURects.Store(r) - } - } + fbuStart := startsFBU(p) + if fbuStart { + m.flushFBUMax() m.fbus.Add(1) } @@ -197,28 +192,41 @@ func (m *metricsConn) Write(p []byte) (int, error) { m.writeNanos.Add(uint64(time.Since(t0).Nanoseconds())) m.bytesOut.Add(uint64(n)) m.writes.Add(1) - if !isFBUHeader(p) { - m.fbuBytes.Add(uint64(n)) - m.fbuRects.Add(1) + + m.fbuBytes.Add(uint64(n)) + if fbuStart { + // Rect count is carried in bytes 2:3 of the FBU header. A standalone + // header records it here; the rect bodies that follow only add bytes. + if len(p) >= 4 { + m.fbuRects.Add(uint64(binary.BigEndian.Uint16(p[2:4]))) + } } + if uint64(n) > m.largestPkt.Load() { m.largestPkt.Store(uint64(n)) } return n, err } +// flushFBUMax folds the bytes and rects accumulated for the FBU that just +// ended into the per-tick high-water marks, then resets the accumulators +// for the next FBU. +func (m *metricsConn) flushFBUMax() { + if b := m.fbuBytes.Swap(0); b > m.maxFBUBytes.Load() { + m.maxFBUBytes.Store(b) + } + if r := m.fbuRects.Swap(0); r > m.maxFBURects.Load() { + m.maxFBURects.Store(r) + } +} + func (m *metricsConn) Close() error { m.closeOnce.Do(func() { close(m.done) if m.recorder == nil { return } - if b := m.fbuBytes.Swap(0); b > m.maxFBUBytes.Load() { - m.maxFBUBytes.Store(b) - } - if r := m.fbuRects.Swap(0); r > m.maxFBURects.Load() { - m.maxFBURects.Store(r) - } + m.flushFBUMax() m.flushTick(true) }) return m.Conn.Close() diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index a820469e677..fa42ea81a6e 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -406,7 +406,7 @@ func TestGateApproval_Disabled_NoApproverCall(t *testing.T) { header := &connectionHeader{mode: ModeAttach} _, err := srv.gateApproval(conn, header) - allowed := err == nil + allowed := err == nil assert.True(t, allowed, "gate must pass through when requireApproval is false") assert.Equal(t, int32(0), app.calls.Load(), "approver must not be called when disabled") } @@ -475,7 +475,7 @@ func TestGateApproval_ApproverDenies(t *testing.T) { header := &connectionHeader{mode: ModeAttach} _, err := srv.gateApproval(conn, header) - allowed := err == nil + allowed := err == nil assert.False(t, allowed, "approver error %v must deny", tc.err) assert.Equal(t, int32(1), app.calls.Load()) }) @@ -493,7 +493,7 @@ func TestGateApproval_ApproverAccepts(t *testing.T) { header := &connectionHeader{mode: ModeAttach, username: "alice"} _, err := srv.gateApproval(conn, header) - allowed := err == nil + allowed := err == nil assert.True(t, allowed, "approver returning nil must let the gate pass") assert.Equal(t, int32(1), app.calls.Load()) assert.Equal(t, "alice", app.lastIn.Username, "header username must reach the approver") @@ -515,7 +515,7 @@ func TestGateApproval_PassesPubKeyHex(t *testing.T) { } header := &connectionHeader{mode: ModeAttach, clientStatic: pub} _, err := srv.gateApproval(conn, header) - allowed := err == nil + allowed := err == nil assert.True(t, allowed) assert.Equal(t, hex.EncodeToString(pub), app.lastIn.PeerPubKey) } diff --git a/client/vnc/server/server_x11.go b/client/vnc/server/server_x11.go index 6e6c53fcb70..6c0b6b643e8 100644 --- a/client/vnc/server/server_x11.go +++ b/client/vnc/server/server_x11.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build (linux && !android) || freebsd package server diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 8a5d9bd31c3..1d87947bba4 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -20,6 +20,12 @@ const ( maxCutTextBytes = 1 << 20 // 1 MiB ) +// handshakeDeadline bounds the RFB handshake exchange (version, security, +// ClientInit). Without it an authenticated peer can park a connection +// between the connection-header deadlines and messageLoop's own deadline, +// pinning a connSem slot. +const handshakeDeadline = 10 * time.Second + const tileSize = 64 // pixels per tile for dirty-rect detection // fullFramePromoteNum/Den trigger full-frame encoding when the dirty area @@ -48,9 +54,12 @@ const ( ) type session struct { - conn net.Conn - capturer ScreenCapturer - injector InputInjector + conn net.Conn + capturer ScreenCapturer + injector InputInjector + // serverW and serverH are the current framebuffer dimensions. The + // encoder goroutine updates them on resize while the message loop reads + // them for pointer scaling, so both accesses are guarded by encMu. serverW int serverH int desktopName string @@ -200,6 +209,11 @@ func (s *session) serve() { } func (s *session) handshake() error { + if err := s.conn.SetDeadline(time.Now().Add(handshakeDeadline)); err != nil { + return fmt.Errorf("set handshake deadline: %w", err) + } + defer s.conn.SetDeadline(time.Time{}) //nolint:errcheck + // Send protocol version. if _, err := io.WriteString(s.conn, rfbProtocolVersion); err != nil { return fmt.Errorf("send version: %w", err) @@ -540,38 +554,6 @@ func (s *session) handleFBUpdateRequest() error { return nil } -// SendDesktopName pushes a DesktopName pseudo-encoded update to the -// client if it advertised support. Lets the client keep its window title -// in sync with the active session (e.g. username changes after login on -// a virtual session). -func (s *session) SendDesktopName(name string) error { - if s.viewOnly { - name = ViewOnlyDesktopNamePrefix + name - } - s.encMu.RLock() - supported := s.clientSupportsDesktopName - s.encMu.RUnlock() - if !supported { - s.desktopName = name - return nil - } - s.desktopName = name - header := make([]byte, 4) - header[0] = serverFramebufferUpdate - binary.BigEndian.PutUint16(header[2:4], 1) - - body := encodeDesktopNameBody(name) - s.writeMu.Lock() - defer s.writeMu.Unlock() - if _, err := s.conn.Write(header); err != nil { - return err - } - if _, err := s.conn.Write(body); err != nil { - return err - } - return nil -} - func (s *session) handleKeyEvent() error { var data [7]byte if _, err := io.ReadFull(s.conn, data[:]); err != nil { @@ -639,7 +621,10 @@ func (s *session) handlePointerEvent() error { s.lastPointerX = x s.lastPointerY = y s.pointerMu.Unlock() - s.injector.InjectPointer(mask, x, y, s.serverW, s.serverH) + s.encMu.RLock() + w, h := s.serverW, s.serverH + s.encMu.RUnlock() + s.injector.InjectPointer(mask, x, y, w, h) return nil } @@ -673,5 +658,8 @@ func (s *session) releaseStickyInput() { s.pointerMu.Lock() x, y := s.lastPointerX, s.lastPointerY s.pointerMu.Unlock() - s.injector.InjectPointer(0, x, y, s.serverW, s.serverH) + s.encMu.RLock() + w, h := s.serverW, s.serverH + s.encMu.RUnlock() + s.injector.InjectPointer(0, x, y, w, h) } diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 529c3bae508..56d1b5ebe9b 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -257,8 +257,10 @@ func (s *session) handleResize() error { return nil } s.log.Debugf("framebuffer resized: %dx%d -> %dx%d", s.serverW, s.serverH, w, h) + s.encMu.Lock() s.serverW = w s.serverH = h + s.encMu.Unlock() // Drop the prev frame so the next encode produces a full update at // the new dimensions rather than diffing against a stale-sized buffer. s.prevFrame = nil @@ -405,7 +407,7 @@ func promoteToBoundingBox(rects [][4]int) ([][4]int, bool) { if bbox < bboxPromoteMinArea { return nil, false } - if dirty*100 < bbox*bboxPromoteDensityPct { + if int64(dirty)*100 < int64(bbox)*bboxPromoteDensityPct { return nil, false } return [][4]int{{x0, y0, w, h}}, true @@ -423,7 +425,7 @@ func (s *session) shouldPromoteToFullFrame(rects [][4]int) bool { for _, r := range rects { dirty += r[2] * r[3] } - return dirty*fullFramePromoteDen > s.serverW*s.serverH*fullFramePromoteNum + return int64(dirty)*fullFramePromoteDen > int64(s.serverW)*int64(s.serverH)*fullFramePromoteNum } // swapPrevCur makes the just-encoded frame the new prevFrame (for the next diff --git a/client/vnc/server/virtual_x11.go b/client/vnc/server/virtual_x11.go index f19dbdbf5b1..2068265fae0 100644 --- a/client/vnc/server/virtual_x11.go +++ b/client/vnc/server/virtual_x11.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build (linux && !android) || freebsd package server diff --git a/client/vnc/server/xauth_x11.go b/client/vnc/server/xauth_x11.go index a8fbae884c5..66855aa4cb6 100644 --- a/client/vnc/server/xauth_x11.go +++ b/client/vnc/server/xauth_x11.go @@ -1,4 +1,4 @@ -//go:build unix && !darwin && !ios && !android +//go:build (linux && !android) || freebsd package server diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 1ebae635a8e..8187657f81d 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -409,12 +409,13 @@ func createGenerateVNCSessionKeyMethod() js.Func { // createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging. // JS signature: createVNCProxy(hostname, port, mode?, username?, keySessionID?, sessionID?, width?, height?, peerPublicKey?) -// mode: "attach" (default) or "session" -// username: required when mode is "session" -// keySessionID: handle for the wasm-resident session keypair minted by netbirdGenerateVNCSessionKey -// sessionID: Windows session ID (0 = console/auto) -// width/height: requested viewport size for session mode (0 = server default) -// peerPublicKey: base64 X25519 static pubkey of the destination peer (required for auth) +// +// mode: "attach" (default) or "session" +// username: required when mode is "session" +// keySessionID: handle for the wasm-resident session keypair minted by netbirdGenerateVNCSessionKey +// sessionID: Windows session ID (0 = console/auto) +// width/height: requested viewport size for session mode (0 = server default) +// peerPublicKey: base64 X25519 static pubkey of the destination peer (required for auth) func createVNCProxyMethod(client *netbird.Client) js.Func { return js.FuncOf(func(_ js.Value, args []js.Value) any { params, err := parseVNCProxyArgs(args) diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 09314f438bf..5df541edf6e 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -21,6 +21,12 @@ import ( var cryptoRandRead = crand.Read +// proxyIDCounter is process-unique across every createVNCProxy call so each +// proxy/connection registers a distinct global handler name. A per-proxy +// counter would restart at 1 for every new VNCProxy, letting a reconnect's +// cleanup delete the new proxy's handler. +var proxyIDCounter atomic.Uint64 + // vncIdentityMagic mirrors the server side in client/vnc/server/server.go. var vncIdentityMagic = []byte("NBV3") @@ -115,7 +121,7 @@ type vncNBClient interface { } type VNCProxy struct { - nbClient vncNBClient + nbClient vncNBClient activeConnections map[string]*vncConnection destinations map[string]vncDestination // pendingHandlers holds the js.Func for handleVNCWebSocket_ between @@ -123,19 +129,18 @@ type VNCProxy struct { // vncConnection for later release. pendingHandlers map[string]js.Func mu sync.Mutex - nextID atomic.Uint64 } type vncDestination struct { - address string - mode byte - username string - sessionPriv []byte - sessionPub []byte - sessionID uint32 - width uint16 - height uint16 - peerPubKey []byte + address string + mode byte + username string + sessionPriv []byte + sessionPub []byte + sessionID uint32 + width uint16 + height uint16 + peerPubKey []byte } type vncConnection struct { @@ -152,6 +157,10 @@ type vncConnection struct { wsHandlerFn js.Func onMessageFn js.Func onCloseFn js.Func + // writeQueue carries inbound WS payloads to a single writer goroutine so + // vncConn.Write calls stay serialized in arrival order. + writeQueue chan []byte + cleanupOnce sync.Once } // NewVNCProxy creates a new VNC proxy. @@ -253,7 +262,7 @@ func (p *VNCProxy) newProxyPromise(address, mode, username string, dest vncDesti go func() { defer executor.Release() - proxyID := fmt.Sprintf("vnc_proxy_%d", p.nextID.Add(1)) + proxyID := fmt.Sprintf("vnc_proxy_%d", proxyIDCounter.Add(1)) p.mu.Lock() if p.destinations == nil { @@ -309,6 +318,7 @@ func (p *VNCProxy) handleWebSocketConnection(ws js.Value, proxyID string) { ctx: ctx, cancel: cancel, wsHandlerFn: handlerFn, + writeQueue: make(chan []byte, 256), } p.mu.Lock() @@ -326,8 +336,7 @@ func (p *VNCProxy) setupWebSocketHandlers(ws js.Value, conn *vncConnection) { if len(args) < 1 { return nil } - data := args[0] - go p.handleWebSocketMessage(conn, data) + p.enqueueWebSocketMessage(conn, args[0]) return nil }) ws.Set("onGoMessage", conn.onMessageFn) @@ -340,7 +349,12 @@ func (p *VNCProxy) setupWebSocketHandlers(ws js.Value, conn *vncConnection) { ws.Set("onGoClose", conn.onCloseFn) } -func (p *VNCProxy) handleWebSocketMessage(conn *vncConnection, data js.Value) { +// enqueueWebSocketMessage copies an inbound WS payload into Go memory and +// hands it to the writer goroutine in arrival order. JS onmessage events are +// delivered single-threaded on the event loop, so copying here preserves +// stream order. When the queue is full the connection is torn down rather +// than dropping bytes, which would corrupt the RFB stream. +func (p *VNCProxy) enqueueWebSocketMessage(conn *vncConnection, data js.Value) { if !data.InstanceOf(js.Global().Get("Uint8Array")) { return } @@ -349,16 +363,30 @@ func (p *VNCProxy) handleWebSocketMessage(conn *vncConnection, data js.Value) { buf := make([]byte, length) js.CopyBytesToGo(buf, data) - conn.mu.Lock() - vncConn := conn.vncConn - conn.mu.Unlock() - - if vncConn == nil { - return + select { + case <-conn.ctx.Done(): + case conn.writeQueue <- buf: + default: + log.Debugf("VNC write queue full for %s; closing connection", conn.id) + conn.cancel() } +} - if _, err := vncConn.Write(buf); err != nil { - log.Debugf("write to VNC server: %v", err) +// writeQueueLoop drains the ordered write queue and performs the blocking +// vncConn.Write sequentially, serializing WS→TCP writes. It exits when the +// connection context is cancelled. +func (p *VNCProxy) writeQueueLoop(conn *vncConnection, vncConn net.Conn) { + for { + select { + case <-conn.ctx.Done(): + return + case buf := <-conn.writeQueue: + if _, err := vncConn.Write(buf); err != nil { + log.Debugf("write to VNC server: %v", err) + conn.cancel() + return + } + } } } @@ -394,9 +422,10 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) { return } - // WS→TCP is handled by the onGoMessage handler set in setupWebSocketHandlers, - // which writes directly to the VNC connection as data arrives from JS. - // Only the TCP→WS direction needs a read loop here. + // WS→TCP payloads are enqueued in arrival order by the onGoMessage handler + // and drained sequentially by a single writer goroutine, keeping the RFB + // stream ordered. The TCP→WS direction has its own read loop. + go p.writeQueueLoop(conn, vncConn) go p.forwardConnToWS(conn) <-conn.ctx.Done() @@ -573,33 +602,47 @@ func (p *VNCProxy) sendToWebSocket(conn *vncConnection, data []byte) { } func (p *VNCProxy) cleanupConnection(conn *vncConnection) { - log.Debugf("cleaning up VNC connection %s", conn.id) - conn.cancel() + conn.cleanupOnce.Do(func() { + log.Debugf("cleaning up VNC connection %s", conn.id) + conn.cancel() - conn.mu.Lock() - vncConn := conn.vncConn - conn.vncConn = nil - conn.mu.Unlock() + conn.mu.Lock() + vncConn := conn.vncConn + conn.vncConn = nil + conn.mu.Unlock() - if vncConn != nil { - if err := vncConn.Close(); err != nil { - log.Debugf("close VNC connection: %v", err) + if vncConn != nil { + if err := vncConn.Close(); err != nil { + log.Debugf("close VNC connection: %v", err) + } } - } - // Remove the global JS handler registered in CreateProxy. - globalName := fmt.Sprintf("handleVNCWebSocket_%s", conn.id) - js.Global().Delete(globalName) + // Remove the global JS handler registered in CreateProxy. + js.Global().Delete(fmt.Sprintf("handleVNCWebSocket_%s", conn.id)) - // Release all js.Func handles; js.FuncOf pins the Go closure and the - // allocations it captures until Release is called. - conn.wsHandlerFn.Release() - conn.onMessageFn.Release() - conn.onCloseFn.Release() + // Detach before releasing so a late WS event surfaces as a TypeError + // instead of calling a released js.Func and panicking the runtime. + if conn.wsHandlers.Truthy() { + conn.wsHandlers.Set("onGoMessage", js.Undefined()) + conn.wsHandlers.Set("onGoClose", js.Undefined()) + } - p.mu.Lock() - delete(p.activeConnections, conn.id) - delete(p.destinations, conn.id) - delete(p.pendingHandlers, conn.id) - p.mu.Unlock() + // wsHandlerFn is the zero js.Func when the pendingHandlers lookup + // missed on a second connect. + if conn.wsHandlerFn.Truthy() { + conn.wsHandlerFn.Release() + } + if conn.onMessageFn.Truthy() { + conn.onMessageFn.Release() + } + if conn.onCloseFn.Truthy() { + conn.onCloseFn.Release() + } + + p.mu.Lock() + delete(p.activeConnections, conn.id) + delete(p.destinations, conn.id) + delete(p.pendingHandlers, conn.id) + p.mu.Unlock() + }) } diff --git a/management/server/http/handlers/peers/temporary_access_permission_test.go b/management/server/http/handlers/peers/temporary_access_permission_test.go index 6a318c8d078..5bbe6592e84 100644 --- a/management/server/http/handlers/peers/temporary_access_permission_test.go +++ b/management/server/http/handlers/peers/temporary_access_permission_test.go @@ -2,6 +2,7 @@ package peers import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -34,7 +35,7 @@ func TestCreateTemporaryAccess_RejectsCallerWithoutPeersCreate(t *testing.T) { // nil so the test fails loudly if the handler tries to call it. permMgr.EXPECT(). ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Create)). - Return(false, nil). + Return(false, context.Background(), nil). Times(1) h := &Handler{ @@ -74,11 +75,11 @@ func TestCreateTemporaryAccess_RejectsCallerWithoutPoliciesCreate(t *testing.T) permMgr.EXPECT(). ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Create)). - Return(true, nil). + Return(true, context.Background(), nil). Times(1) permMgr.EXPECT(). ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Policies), gomock.Eq(operations.Create)). - Return(false, nil). + Return(false, context.Background(), nil). Times(1) h := &Handler{ diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 8c3e9dc3da7..e13defb51c2 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -138,7 +138,6 @@ type Flags struct { DisableIPv6 bool LazyConnectionEnabled bool - } // PeerSystemMeta is a metadata of a Peer machine system diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index c6ced264221..6e18a5c4be7 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -2536,7 +2536,7 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t if len(policyIDs) == 0 { return nil, nil } - const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user FROM policy_rules WHERE policy_id = ANY($1)` + const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user, session_pub_key, session_display_name FROM policy_rules WHERE policy_id = ANY($1)` rows, err := s.pool.Query(ctx, query, policyIDs) if err != nil { return nil, err @@ -2545,8 +2545,8 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t var r types.PolicyRule var dest, destRes, sources, sourceRes, ports, portRanges, authorizedGroups []byte var enabled, bidirectional sql.NullBool - var authorizedUser sql.NullString - err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges, &authorizedGroups, &authorizedUser) + var authorizedUser, sessionPubKey, sessionDisplayName sql.NullString + err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges, &authorizedGroups, &authorizedUser, &sessionPubKey, &sessionDisplayName) if err == nil { if enabled.Valid { r.Enabled = enabled.Bool @@ -2578,6 +2578,12 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t if authorizedUser.Valid { r.AuthorizedUser = authorizedUser.String } + if sessionPubKey.Valid { + r.SessionPubKey = sessionPubKey.String + } + if sessionDisplayName.Valid { + r.SessionDisplayName = sessionDisplayName.String + } } return &r, err }) diff --git a/management/server/types/account.go b/management/server/types/account.go index f9228b54a8c..2831f8bf151 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -14,7 +14,6 @@ import ( "github.com/rs/xid" log "github.com/sirupsen/logrus" - auth "github.com/netbirdio/netbird/shared/sessionauth" nbdns "github.com/netbirdio/netbird/dns" proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -29,6 +28,7 @@ import ( "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" + auth "github.com/netbirdio/netbird/shared/sessionauth" "github.com/netbirdio/netbird/version" ) @@ -170,7 +170,6 @@ func (a *Account) GetGroup(groupID string) *Group { return a.Groups[groupID] } - func (a *Account) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peer *nbpeer.Peer, diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index 1182eaf40b6..99feb4754d7 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -9,13 +9,13 @@ import ( "strings" "time" - auth "github.com/netbirdio/netbird/shared/sessionauth" nbdns "github.com/netbirdio/netbird/dns" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + auth "github.com/netbirdio/netbird/shared/sessionauth" ) type NetworkMapComponents struct { @@ -109,7 +109,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { peerGroups := c.GetPeerGroups(targetPeerID) - connRes := c.getPeerConnectionResources(targetPeerID) + connRes := c.getPeerConnectionResources(ctx, targetPeerID) aclPeers := connRes.peers peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers) @@ -182,7 +182,7 @@ type peerConnectionResult struct { sshEnabled bool } -func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) peerConnectionResult { +func (c *NetworkMapComponents) getPeerConnectionResources(ctx context.Context, targetPeerID string) peerConnectionResult { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { return peerConnectionResult{} @@ -202,7 +202,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) p if !rule.Enabled { continue } - c.applyPolicyRule(rule, policy.SourcePostureChecks, targetPeer, targetPeerID, generateResources, state) + c.applyPolicyRule(ctx, rule, policy.SourcePostureChecks, targetPeer, targetPeerID, generateResources, state) } } @@ -218,6 +218,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) p } func (c *NetworkMapComponents) applyPolicyRule( + ctx context.Context, rule *PolicyRule, sourcePostureChecks []string, targetPeer *nbpeer.Peer, @@ -229,8 +230,12 @@ func (c *NetworkMapComponents) applyPolicyRule( destinationPeers, peerInDestinations := c.resolveRuleEndpoint(rule.DestinationResource, rule.Destinations, targetPeerID, nil) cb := ruleAuthCallbacks{ - collectSSHUsers: c.collectAuthorizedUsers, - collectVNCUsers: c.collectAuthorizedUsers, + collectSSHUsers: func(r *PolicyRule, t map[string]map[string]struct{}) { + c.collectAuthorizedUsers(ctx, r, t) + }, + collectVNCUsers: func(r *PolicyRule, t map[string]map[string]struct{}) { + c.collectAuthorizedUsers(ctx, r, t) + }, getAllowedUserIDs: c.getAllowedUserIDs, } applyResolvedRuleToState(rule, sourcePeers, destinationPeers, peerInSources, peerInDestinations, targetPeer.SSHEnabled, generateResources, cb, state) @@ -249,10 +254,10 @@ func (c *NetworkMapComponents) resolveRuleEndpoint( } // collectAuthorizedUsers populates the target map with authorized user mappings from the rule. -func (c *NetworkMapComponents) collectAuthorizedUsers(rule *PolicyRule, target map[string]map[string]struct{}) { +func (c *NetworkMapComponents) collectAuthorizedUsers(ctx context.Context, rule *PolicyRule, target map[string]map[string]struct{}) { switch { case len(rule.AuthorizedGroups) > 0: - mergeAuthorizedGroupUsers(context.Background(), rule.AuthorizedGroups, c.GroupIDToUserIDs, target) + mergeAuthorizedGroupUsers(ctx, rule.AuthorizedGroups, c.GroupIDToUserIDs, target) case rule.AuthorizedUser != "": ensureWildcardUser(target, rule.AuthorizedUser) default: diff --git a/management/server/types/policy_authorized_users.go b/management/server/types/policy_authorized_users.go index 486446f9534..46bce625d10 100644 --- a/management/server/types/policy_authorized_users.go +++ b/management/server/types/policy_authorized_users.go @@ -6,8 +6,8 @@ import ( log "github.com/sirupsen/logrus" - auth "github.com/netbirdio/netbird/shared/sessionauth" nbpeer "github.com/netbirdio/netbird/management/server/peer" + auth "github.com/netbirdio/netbird/shared/sessionauth" ) // peerConnResolveState carries the in-progress maps mutated by per-rule diff --git a/management/server/types/policy_authorized_users_security_test.go b/management/server/types/policy_authorized_users_security_test.go index b040c9d2ffd..28b1dc07227 100644 --- a/management/server/types/policy_authorized_users_security_test.go +++ b/management/server/types/policy_authorized_users_security_test.go @@ -1,6 +1,10 @@ package types -import "testing" +import ( + "testing" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) // TestHandleVNCRule_BidirectionalDistributesPubkeyToSourcePeer covers the // latent bug where a bidirectional VNC rule used to drop the @@ -83,3 +87,68 @@ func TestHandleVNCRule_DestinationAlwaysGetsPubkey(t *testing.T) { t.Fatalf("expected 1 session pubkey for destination peer, got %d", len(state.vncSessionPubKeys)) } } + +// TestApplyResolvedRule_BidirectionalSSHEnablesSourcePeer locks the +// bidirectional widening for netbird-ssh rules: a peer that appears only +// in the rule's sources of a bidirectional SSH rule must get SSH enabled +// and its authorized users collected, because the rule grants access in +// both directions. A unidirectional rule must not do this for a +// source-only peer. +func TestApplyResolvedRule_BidirectionalSSHEnablesSourcePeer(t *testing.T) { + collected := false + cb := ruleAuthCallbacks{ + collectSSHUsers: func(_ *PolicyRule, target map[string]map[string]struct{}) { + collected = true + target["local"] = map[string]struct{}{"user1": {}} + }, + } + rule := &PolicyRule{ + Protocol: PolicyRuleProtocolNetbirdSSH, + Bidirectional: true, + } + state := &peerConnResolveState{ + authorizedUsers: make(map[string]map[string]struct{}), + vncAuthorizedUsers: make(map[string]map[string]struct{}), + } + + applyResolvedRuleToState(rule, nil, nil, true /*peerInSources*/, false /*peerInDestinations*/, false, func(*PolicyRule, []*nbpeer.Peer, int) {}, cb, state) + + if !state.sshEnabled { + t.Fatal("expected SSH enabled on source-side peer of bidirectional SSH rule") + } + if !collected { + t.Fatal("expected authorized users collected on source-side peer of bidirectional SSH rule") + } + if _, ok := state.authorizedUsers["local"]; !ok { + t.Fatal("expected authorized users map populated for source-side peer") + } +} + +// TestApplyResolvedRule_UnidirectionalSSHSkipsSourcePeer is the negative +// counterpart: a unidirectional SSH rule must not enable SSH for a peer +// that appears only in sources. +func TestApplyResolvedRule_UnidirectionalSSHSkipsSourcePeer(t *testing.T) { + collected := false + cb := ruleAuthCallbacks{ + collectSSHUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) { + collected = true + }, + } + rule := &PolicyRule{ + Protocol: PolicyRuleProtocolNetbirdSSH, + Bidirectional: false, + } + state := &peerConnResolveState{ + authorizedUsers: make(map[string]map[string]struct{}), + vncAuthorizedUsers: make(map[string]map[string]struct{}), + } + + applyResolvedRuleToState(rule, nil, nil, true /*peerInSources*/, false /*peerInDestinations*/, false, func(*PolicyRule, []*nbpeer.Peer, int) {}, cb, state) + + if state.sshEnabled { + t.Fatal("expected SSH NOT enabled on source-only peer of unidirectional SSH rule") + } + if collected { + t.Fatal("expected NO authorized users collected on source-only peer of unidirectional SSH rule") + } +} diff --git a/shared/sessionauth/auth.go b/shared/sessionauth/auth.go index e438d883156..9628a6b0de6 100644 --- a/shared/sessionauth/auth.go +++ b/shared/sessionauth/auth.go @@ -20,11 +20,11 @@ const ( ) var ( - ErrEmptyUserID = errors.New("JWT user ID is empty") - ErrUserNotAuthorized = errors.New("user is not authorized to access this peer") - ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user") - ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user") - ErrSessionKeyNotKnown = errors.New("session pubkey not registered") + ErrEmptyUserID = errors.New("JWT user ID is empty") + ErrUserNotAuthorized = errors.New("user is not authorized to access this peer") + ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user") + ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user") + ErrSessionKeyNotKnown = errors.New("session pubkey not registered") ) // Authorizer handles SSH fine-grained access control authorization @@ -83,8 +83,8 @@ type SessionPubKey struct { // NewAuthorizer creates a new SSH authorizer with empty configuration func NewAuthorizer() *Authorizer { a := &Authorizer{ - userIDClaim: DefaultUserIDClaim, - machineUsers: make(map[string][]uint32), + userIDClaim: DefaultUserIDClaim, + machineUsers: make(map[string][]uint32), sessionPubKeys: make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash), sessionDisplayNames: make(map[[sessionPubKeyLen]byte]string), } diff --git a/util/capture/text.go b/util/capture/text.go index 95068136a82..6ffdebef01f 100644 --- a/util/capture/text.go +++ b/util/capture/text.go @@ -466,19 +466,19 @@ func isWellKnownVNCPort(p uint16) bool { // message-type recognitions fire only when length matches the fixed // size for that type, to avoid mis-tagging Noise handshake bytes. func annotateVNCClientToServer(p []byte) string { - if len(p) >= 10 && (p[0] == 0 || p[0] == 1) { - userLen := int(p[1]) - // width and height are uint16 fields the dashboard often leaves - // zero (default). A header without an OS user has total length - // 10; with one, 10+userLen. - if 10+userLen <= len(p) { + if len(p) >= 11 && (p[0] == 0 || p[0] == 1) { + // Connection header layout: mode(1) + u16 BE username length + + // username(N), then sessionID(4) + width(2) + height(2). Prefix is + // 3+N, full header 11+N. + userLen := int(binary.BigEndian.Uint16(p[1:3])) + if 11+userLen <= len(p) { mode := "attach" if p[0] == 1 { mode = "session" } tag := fmt.Sprintf("connect mode=%s", mode) - if userLen > 0 { - tag += fmt.Sprintf(" user(%d)", userLen) + if userLen > 0 && 3+userLen <= len(p) { + tag += fmt.Sprintf(" user(%s)", p[3:3+userLen]) } return tag } @@ -528,9 +528,11 @@ func annotateVNCServerToClient(p []byte) string { // matchRFBSecurityFailure recognises the RFB 3.8 security-result body the // server sends when authentication or session setup fails. Format: -// byte 0 : 0x00 (security types count = 0 = failure) -// bytes 1-4: uint32 reason length -// bytes 5+: reason text +// +// byte 0 : 0x00 (security types count = 0 = failure) +// bytes 1-4: uint32 reason length +// bytes 5+: reason text +// // Returns the reason text and ok=true when the length self-checks. func matchRFBSecurityFailure(p []byte) (string, bool) { if len(p) < 5 || p[0] != 0 { From fd7bf982c3b16bac8c4d066e9ad121bb12f8ca60 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 28 Jun 2026 17:41:50 +0200 Subject: [PATCH 093/151] Split CreateTemporaryAccess into smaller functions --- .../http/handlers/peers/peers_handler.go | 131 ++++++++++-------- 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index e4fc2db51c9..07dbb78370a 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -465,31 +465,14 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } - // Explicit defence-in-depth gate before any business logic: we already - // rely on AddPeer/SavePolicy to enforce the Peers.Create and - // Policies.Create permissions, but checking up-front means a future - // refactor that bypasses one of those calls can't silently widen the - // endpoint's authority. - allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Peers, operations.Create) + ctx, err := h.validateTemporaryAccessPermissions(r.Context(), userAuth.AccountId, userAuth.UserId) if err != nil { - util.WriteError(ctx, status.NewPermissionValidationError(err), w) - return - } else if !allowed { - util.WriteError(ctx, status.NewPermissionDeniedError(), w) - return - } - allowed, ctx, err = h.permissionsManager.ValidateUserPermissions(ctx, userAuth.AccountId, userAuth.UserId, modules.Policies, operations.Create) - if err != nil { - util.WriteError(ctx, status.NewPermissionValidationError(err), w) - return - } else if !allowed { - util.WriteError(ctx, status.NewPermissionDeniedError(), w) + util.WriteError(ctx, err, w) return } var req api.PeerTemporaryAccessRequest - err = json.NewDecoder(r.Body).Decode(&req) - if err != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) return } @@ -497,26 +480,10 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) newPeer := &nbpeer.Peer{} newPeer.FromAPITemporaryAccessRequest(&req) - parsedRules := make([]struct { - raw string - protocol types.PolicyRuleProtocolType - portRange types.RulePortRange - }, 0, len(req.Rules)) - needsVNCKey := false - for _, rule := range req.Rules { - protocol, portRange, err := types.ParseRuleString(rule) - if err != nil { - util.WriteError(r.Context(), err, w) - return - } - if protocol == types.PolicyRuleProtocolNetbirdVNC { - needsVNCKey = true - } - parsedRules = append(parsedRules, struct { - raw string - protocol types.PolicyRuleProtocolType - portRange types.RulePortRange - }{rule, protocol, portRange}) + parsedRules, needsVNCKey, err := parseTemporaryAccessRules(req.Rules) + if err != nil { + util.WriteError(r.Context(), err, w) + return } var vncSessionPubKey string @@ -540,7 +507,72 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } - for _, pr := range parsedRules { + if err := h.createTemporaryAccessPolicies(r.Context(), userAuth, peer, targetPeer, parsedRules, vncSessionPubKey); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + resp := &api.PeerTemporaryAccessResponse{ + Id: peer.ID, + Name: peer.Name, + Rules: req.Rules, + TargetPubKey: targetPeer.Key, + } + + util.WriteJSONObject(r.Context(), w, resp) +} + +// temporaryAccessRule holds a parsed temporary-access rule string alongside +// the protocol and port range it resolves to. +type temporaryAccessRule struct { + raw string + protocol types.PolicyRuleProtocolType + portRange types.RulePortRange +} + +// validateTemporaryAccessPermissions enforces the Peers.Create and +// Policies.Create permissions up front. AddPeer/SavePolicy enforce them too, +// but the explicit gate keeps a future refactor that bypasses one of those +// calls from silently widening the endpoint's authority. It returns the +// context updated by the permission checks. +func (h *Handler) validateTemporaryAccessPermissions(ctx context.Context, accountID, userID string) (context.Context, error) { + allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create) + if err != nil { + return ctx, status.NewPermissionValidationError(err) + } else if !allowed { + return ctx, status.NewPermissionDeniedError() + } + allowed, ctx, err = h.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Create) + if err != nil { + return ctx, status.NewPermissionValidationError(err) + } else if !allowed { + return ctx, status.NewPermissionDeniedError() + } + return ctx, nil +} + +// parseTemporaryAccessRules parses the rule strings from the request and +// reports whether any of them is a VNC rule, which requires a session key. +func parseTemporaryAccessRules(rules []string) ([]temporaryAccessRule, bool, error) { + parsed := make([]temporaryAccessRule, 0, len(rules)) + needsVNCKey := false + for _, rule := range rules { + protocol, portRange, err := types.ParseRuleString(rule) + if err != nil { + return nil, false, err + } + if protocol == types.PolicyRuleProtocolNetbirdVNC { + needsVNCKey = true + } + parsed = append(parsed, temporaryAccessRule{raw: rule, protocol: protocol, portRange: portRange}) + } + return parsed, needsVNCKey, nil +} + +// createTemporaryAccessPolicies creates one temporary-access policy per parsed +// rule, allowing peer to reach targetPeer over the rule's protocol and ports. +func (h *Handler) createTemporaryAccessPolicies(ctx context.Context, userAuth auth.UserAuth, peer, targetPeer *nbpeer.Peer, rules []temporaryAccessRule, vncSessionPubKey string) error { + for _, pr := range rules { policy := &types.Policy{ AccountID: userAuth.AccountId, Description: "Temporary access policy for peer " + peer.Name, @@ -569,23 +601,14 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) } if pr.protocol == types.PolicyRuleProtocolNetbirdVNC { policy.Rules[0].SessionPubKey = vncSessionPubKey - policy.Rules[0].SessionDisplayName = h.displayNameForUser(r.Context(), userAuth) + policy.Rules[0].SessionDisplayName = h.displayNameForUser(ctx, userAuth) } - if _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true); err != nil { - util.WriteError(r.Context(), err, w) - return + if _, err := h.accountManager.SavePolicy(ctx, userAuth.AccountId, userAuth.UserId, policy, true); err != nil { + return err } } - - resp := &api.PeerTemporaryAccessResponse{ - Id: peer.ID, - Name: peer.Name, - Rules: req.Rules, - TargetPubKey: targetPeer.Key, - } - - util.WriteJSONObject(r.Context(), w, resp) + return nil } // validateVNCSessionPubKey ensures the request carries a base64-encoded From 02e7c0e5d276cbced983a1616631fc1de1c9e809 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 12 Jul 2026 18:46:41 +0200 Subject: [PATCH 094/151] Port VNC settings and connection-approval prompt to the Wails UI --- client/ui/frontend/src/app.tsx | 2 + .../src/modules/approval/ApprovalDialog.tsx | 170 ++++++++++++++++++ .../modules/settings/SettingsNavigation.tsx | 8 + .../src/modules/settings/SettingsPage.tsx | 4 + .../src/modules/settings/SettingsVNC.tsx | 32 ++++ client/ui/i18n/locales/de/common.json | 60 +++++++ client/ui/i18n/locales/en/common.json | 80 +++++++++ client/ui/i18n/locales/es/common.json | 60 +++++++ client/ui/i18n/locales/fr/common.json | 60 +++++++ client/ui/i18n/locales/hu/common.json | 60 +++++++ client/ui/i18n/locales/it/common.json | 60 +++++++ client/ui/i18n/locales/pt/common.json | 60 +++++++ client/ui/i18n/locales/ru/common.json | 60 +++++++ client/ui/i18n/locales/zh-CN/common.json | 60 +++++++ client/ui/main.go | 1 + client/ui/services/approval.go | 36 ++++ client/ui/services/settings.go | 10 +- client/ui/services/windowmanager.go | 78 ++++++++ client/ui/tray_events.go | 32 ++++ 19 files changed, 932 insertions(+), 1 deletion(-) create mode 100644 client/ui/frontend/src/modules/approval/ApprovalDialog.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsVNC.tsx create mode 100644 client/ui/services/approval.go diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 7f1359510e3..8ea1fa60745 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import "./globals.css"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx"; +import ApprovalDialog from "@/modules/approval/ApprovalDialog.tsx"; import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx"; import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx"; import ErrorDialog from "@/modules/error/ErrorDialog.tsx"; @@ -48,6 +49,7 @@ Promise.all([ path={"session-expiration"} element={} /> + } /> } /> } /> diff --git a/client/ui/frontend/src/modules/approval/ApprovalDialog.tsx b/client/ui/frontend/src/modules/approval/ApprovalDialog.tsx new file mode 100644 index 00000000000..dd442368bd7 --- /dev/null +++ b/client/ui/frontend/src/modules/approval/ApprovalDialog.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { MonitorIcon } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { Approval, WindowManager } from "@bindings/services"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; + +const WINDOW_WIDTH = 360; +// Fallback window so a missing/unparseable expires_at can't leave the prompt open forever. +const FALLBACK_SECONDS = 13; + +// shortFingerprint groups a hex key as XXXX-XXXX-XXXX-XXXX (16 chars). Mirrors the +// daemon's approval.ShortKeyFingerprint so the value matches an out-of-band reference. +function shortFingerprint(hexKey: string): string { + if (hexKey.length < 8) return ""; + const src = hexKey.slice(0, 16); + return src.match(/.{1,4}/g)?.join("-") ?? src; +} + +type Row = { label: string; value: string; mono?: boolean }; + +export default function ApprovalDialog() { + const { t } = useTranslation(); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const [params] = useSearchParams(); + const [busy, setBusy] = useState(false); + + const requestID = params.get("request_id") ?? ""; + const kind = params.get("kind") ?? ""; + const initiator = params.get("initiator") ?? ""; + const peerName = params.get("peer_name") ?? ""; + const sourceIP = params.get("source_ip") ?? ""; + const username = params.get("username") ?? ""; + const peerPubKey = params.get("peer_pubkey") ?? ""; + const expiresAt = params.get("expires_at") ?? ""; + + const deadline = useMemo(() => { + const parsed = Date.parse(expiresAt); + return Number.isFinite(parsed) ? parsed : Date.now() + FALLBACK_SECONDS * 1000; + }, [expiresAt]); + + const title = useMemo(() => { + switch (kind) { + case "vnc": + return t("approval.title.vnc"); + case "ssh": + return t("approval.title.ssh"); + default: + return t("approval.title.default"); + } + }, [kind, t]); + + const rows = useMemo(() => { + const out: Row[] = []; + // The display name is dashboard-supplied and not cryptographically + // asserted; the key fingerprint below IS, so show both. + if (initiator) out.push({ label: t("approval.field.user"), value: initiator }); + const fp = shortFingerprint(peerPubKey); + if (fp) out.push({ label: t("approval.field.keyFingerprint"), value: fp, mono: true }); + if (peerName) out.push({ label: t("approval.field.peer"), value: peerName }); + if (sourceIP && sourceIP !== peerName) + out.push({ label: t("approval.field.sourceIp"), value: sourceIP, mono: true }); + if (username) out.push({ label: t("approval.field.osUser"), value: username }); + return out; + }, [initiator, peerPubKey, peerName, sourceIP, username, t]); + + const respond = useCallback( + async (accept: boolean, viewOnly: boolean) => { + if (busy) return; + setBusy(true); + try { + if (requestID) { + await Approval.Respond(requestID, accept, viewOnly); + } + } catch (e) { + console.error("respond approval failed", e); + } finally { + WindowManager.CloseApproval().catch(console.error); + } + }, + [busy, requestID], + ); + + const secondsLeft = () => Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + const [remaining, setRemaining] = useState(secondsLeft); + const closedRef = useRef(false); + useEffect(() => { + const id = globalThis.setInterval(() => { + const left = secondsLeft(); + setRemaining(left); + // On the deadline the daemon auto-denies; just close the prompt. + if (left <= 0 && !closedRef.current) { + closedRef.current = true; + WindowManager.CloseApproval().catch(console.error); + } + }, 1000); + return () => globalThis.clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deadline]); + + const showViewOnly = kind === "vnc"; + + return ( + + + + {title} + + {rows.length > 0 && ( +
+ {rows.map((row) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+ )} + +
+ {t("approval.countdown", { seconds: remaining })} +
+ + + + {showViewOnly && ( + + )} + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index 816dcb71f90..b28e05fb045 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -8,6 +8,7 @@ import { BoltIcon, InfoIcon, LifeBuoyIcon, + MonitorIcon, NetworkIcon, ShieldIcon, SlidersHorizontalIcon, @@ -63,6 +64,13 @@ export const SettingsNavigation = () => { title={t("settings.tabs.ssh")} /> )} + {!features.disableUpdateSettings && ( + + )} {!features.disableUpdateSettings && ( = { [Tab.Security]: , [Tab.Profiles]: , [Tab.SSH]: , + [Tab.VNC]: , [Tab.Advanced]: , [Tab.Troubleshooting]: , [Tab.About]: , @@ -55,6 +58,7 @@ export const SettingsPage = () => { [Tab.Security]: editable, [Tab.Profiles]: !features.disableProfiles, [Tab.SSH]: mdm.allowServerSSH ?? editable, + [Tab.VNC]: editable, [Tab.Advanced]: editable, [Tab.Troubleshooting]: true, [Tab.About]: true, diff --git a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx new file mode 100644 index 00000000000..1da4a2c26a9 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; + +export function SettingsVNC() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const isVNCServerEnabled = config.serverVncAllowed; + + return ( + <> + + setField("serverVncAllowed", v)} + label={t("settings.vnc.server.label")} + helpText={t("settings.vnc.server.help")} + /> + + + + setField("disableVncApproval", !v)} + label={t("settings.vnc.approval.label")} + helpText={t("settings.vnc.approval.help")} + /> + + + ); +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 5e0d8096d8c..4e9edb3cfc9 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "Vorgang fehlgeschlagen." + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "Server" + }, + "settings.vnc.section.approval": { + "message": "Genehmigung" + }, + "settings.vnc.server.label": { + "message": "VNC-Server aktivieren" + }, + "settings.vnc.server.help": { + "message": "Den NetBird-VNC-Server auf diesem Host ausführen, damit autorisierte Peers den Bildschirm ansehen oder steuern können." + }, + "settings.vnc.approval.label": { + "message": "Verbindungsgenehmigung erforderlich" + }, + "settings.vnc.approval.help": { + "message": "Auf diesem Host eine Aufforderung anzeigen, die bestätigt werden muss, bevor eine eingehende VNC-Verbindung zugelassen wird." + }, + "window.title.approval": { + "message": "Verbindungsanfrage" + }, + "approval.title.vnc": { + "message": "VNC-Verbindung zulassen?" + }, + "approval.title.ssh": { + "message": "SSH-Verbindung zulassen?" + }, + "approval.title.default": { + "message": "Eingehende Verbindung zulassen?" + }, + "approval.field.user": { + "message": "Von Benutzer" + }, + "approval.field.keyFingerprint": { + "message": "Schlüssel-Fingerabdruck" + }, + "approval.field.peer": { + "message": "Über Peer" + }, + "approval.field.sourceIp": { + "message": "Quell-IP" + }, + "approval.field.osUser": { + "message": "Betriebssystem-Benutzer" + }, + "approval.countdown": { + "message": "Automatische Ablehnung in {seconds}s" + }, + "approval.action.allow": { + "message": "Zulassen" + }, + "approval.action.allowViewOnly": { + "message": "Zulassen (nur ansehen)" + }, + "approval.action.deny": { + "message": "Ablehnen" } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 42d40ec308c..6c510a170e5 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -683,6 +683,10 @@ "message": "SSH", "description": "Settings tab label: SSH. Acronym — keep as-is." }, + "settings.tabs.vnc": { + "message": "VNC", + "description": "Settings tab label: VNC. Acronym — keep as-is." + }, "settings.tabs.advanced": { "message": "Advanced", "description": "Settings tab label: Advanced. Keep short." @@ -951,6 +955,30 @@ "message": "Second(s)", "description": "Unit suffix shown after the JWT TTL number field. The '(s)' marks an optional plural." }, + "settings.vnc.section.server": { + "message": "Server", + "description": "Section heading: Server (VNC settings)." + }, + "settings.vnc.section.approval": { + "message": "Approval", + "description": "Section heading: Approval (VNC connection approval settings)." + }, + "settings.vnc.server.label": { + "message": "Enable VNC Server", + "description": "Toggle label: enable the embedded VNC server." + }, + "settings.vnc.server.help": { + "message": "Run the NetBird VNC server on this host so authorized peers can view or control its screen.", + "description": "Helper text for the VNC server toggle." + }, + "settings.vnc.approval.label": { + "message": "Require Connection Approval", + "description": "Toggle label: prompt for approval before each inbound VNC connection." + }, + "settings.vnc.approval.help": { + "message": "Show a prompt on this host that must be accepted before an incoming VNC connection is allowed.", + "description": "Helper text for the VNC connection-approval toggle." + }, "settings.advanced.section.interface": { "message": "Interface", "description": "Section heading: Interface (network-interface settings)." @@ -1363,6 +1391,58 @@ "message": "Session Expiring", "description": "OS window-chrome title for the session-expiration window." }, + "window.title.approval": { + "message": "Connection Request", + "description": "OS window-chrome title for the inbound-connection approval window." + }, + "approval.title.vnc": { + "message": "Allow VNC Connection?", + "description": "Approval dialog heading for an inbound VNC connection." + }, + "approval.title.ssh": { + "message": "Allow SSH Connection?", + "description": "Approval dialog heading for an inbound SSH connection." + }, + "approval.title.default": { + "message": "Allow Incoming Connection?", + "description": "Approval dialog heading for an inbound connection of unknown kind." + }, + "approval.field.user": { + "message": "From user", + "description": "Approval dialog row label: the initiating user's display name." + }, + "approval.field.keyFingerprint": { + "message": "Key fingerprint", + "description": "Approval dialog row label: the connecting peer's cryptographic key fingerprint." + }, + "approval.field.peer": { + "message": "Via peer", + "description": "Approval dialog row label: the peer the connection arrives through." + }, + "approval.field.sourceIp": { + "message": "Source IP", + "description": "Approval dialog row label: the source IP address of the connection." + }, + "approval.field.osUser": { + "message": "OS user", + "description": "Approval dialog row label: the target operating-system user." + }, + "approval.countdown": { + "message": "Auto-deny in {seconds}s", + "description": "Approval dialog countdown; {seconds} is the remaining whole seconds before the daemon auto-denies." + }, + "approval.action.allow": { + "message": "Allow", + "description": "Approval dialog button: allow the connection." + }, + "approval.action.allowViewOnly": { + "message": "Allow (view only)", + "description": "Approval dialog button: allow the connection in view-only mode." + }, + "approval.action.deny": { + "message": "Deny", + "description": "Approval dialog button: deny the connection." + }, "window.title.updating": { "message": "Updating", "description": "OS window-chrome title for the update / install window." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 47faee61f0e..f55b4015ccd 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "La operación falló." + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "Servidor" + }, + "settings.vnc.section.approval": { + "message": "Aprobación" + }, + "settings.vnc.server.label": { + "message": "Habilitar el servidor VNC" + }, + "settings.vnc.server.help": { + "message": "Ejecuta el servidor VNC de NetBird en este host para que los peers autorizados puedan ver o controlar su pantalla." + }, + "settings.vnc.approval.label": { + "message": "Requerir aprobación de conexión" + }, + "settings.vnc.approval.help": { + "message": "Mostrar en este host una solicitud que debe aceptarse antes de permitir una conexión VNC entrante." + }, + "window.title.approval": { + "message": "Solicitud de conexión" + }, + "approval.title.vnc": { + "message": "¿Permitir la conexión VNC?" + }, + "approval.title.ssh": { + "message": "¿Permitir la conexión SSH?" + }, + "approval.title.default": { + "message": "¿Permitir la conexión entrante?" + }, + "approval.field.user": { + "message": "Del usuario" + }, + "approval.field.keyFingerprint": { + "message": "Huella de la clave" + }, + "approval.field.peer": { + "message": "A través del peer" + }, + "approval.field.sourceIp": { + "message": "IP de origen" + }, + "approval.field.osUser": { + "message": "Usuario del SO" + }, + "approval.countdown": { + "message": "Rechazo automático en {seconds}s" + }, + "approval.action.allow": { + "message": "Permitir" + }, + "approval.action.allowViewOnly": { + "message": "Permitir (solo ver)" + }, + "approval.action.deny": { + "message": "Denegar" } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index be0836e9356..12162886c5c 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "L’opération a échoué." + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "Serveur" + }, + "settings.vnc.section.approval": { + "message": "Approbation" + }, + "settings.vnc.server.label": { + "message": "Activer le serveur VNC" + }, + "settings.vnc.server.help": { + "message": "Exécuter le serveur VNC de NetBird sur cet hôte afin que les pairs autorisés puissent voir ou contrôler son écran." + }, + "settings.vnc.approval.label": { + "message": "Exiger l'approbation des connexions" + }, + "settings.vnc.approval.help": { + "message": "Afficher sur cet hôte une invite qui doit être acceptée avant d'autoriser une connexion VNC entrante." + }, + "window.title.approval": { + "message": "Demande de connexion" + }, + "approval.title.vnc": { + "message": "Autoriser la connexion VNC ?" + }, + "approval.title.ssh": { + "message": "Autoriser la connexion SSH ?" + }, + "approval.title.default": { + "message": "Autoriser la connexion entrante ?" + }, + "approval.field.user": { + "message": "De l'utilisateur" + }, + "approval.field.keyFingerprint": { + "message": "Empreinte de clé" + }, + "approval.field.peer": { + "message": "Via le pair" + }, + "approval.field.sourceIp": { + "message": "IP source" + }, + "approval.field.osUser": { + "message": "Utilisateur du système" + }, + "approval.countdown": { + "message": "Refus automatique dans {seconds}s" + }, + "approval.action.allow": { + "message": "Autoriser" + }, + "approval.action.allowViewOnly": { + "message": "Autoriser (lecture seule)" + }, + "approval.action.deny": { + "message": "Refuser" } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b5491836473..3da67ce5554 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "A művelet meghiúsult." + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "Szerver" + }, + "settings.vnc.section.approval": { + "message": "Jóváhagyás" + }, + "settings.vnc.server.label": { + "message": "VNC szerver engedélyezése" + }, + "settings.vnc.server.help": { + "message": "A NetBird VNC szerver futtatása ezen a gépen, hogy az arra jogosult partnerek megtekinthessék vagy vezérelhessék a képernyőjét." + }, + "settings.vnc.approval.label": { + "message": "Kapcsolat jóváhagyásának megkövetelése" + }, + "settings.vnc.approval.help": { + "message": "Megerősítést kérő ablak megjelenítése ezen a gépen, amelyet el kell fogadni a bejövő VNC-kapcsolat engedélyezése előtt." + }, + "window.title.approval": { + "message": "Kapcsolódási kérés" + }, + "approval.title.vnc": { + "message": "Engedélyezi a VNC-kapcsolatot?" + }, + "approval.title.ssh": { + "message": "Engedélyezi az SSH-kapcsolatot?" + }, + "approval.title.default": { + "message": "Engedélyezi a bejövő kapcsolatot?" + }, + "approval.field.user": { + "message": "Felhasználótól" + }, + "approval.field.keyFingerprint": { + "message": "Kulcs ujjlenyomata" + }, + "approval.field.peer": { + "message": "Partneren keresztül" + }, + "approval.field.sourceIp": { + "message": "Forrás IP" + }, + "approval.field.osUser": { + "message": "OS-felhasználó" + }, + "approval.countdown": { + "message": "Automatikus elutasítás {seconds} mp múlva" + }, + "approval.action.allow": { + "message": "Engedélyezés" + }, + "approval.action.allowViewOnly": { + "message": "Engedélyezés (csak megtekintés)" + }, + "approval.action.deny": { + "message": "Elutasítás" } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 603364fa2f6..54e0ffb185f 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "Operazione non riuscita." + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "Server" + }, + "settings.vnc.section.approval": { + "message": "Approvazione" + }, + "settings.vnc.server.label": { + "message": "Abilita server VNC" + }, + "settings.vnc.server.help": { + "message": "Esegui il server VNC di NetBird su questo host in modo che i peer autorizzati possano visualizzarne o controllarne lo schermo." + }, + "settings.vnc.approval.label": { + "message": "Richiedi l'approvazione della connessione" + }, + "settings.vnc.approval.help": { + "message": "Mostra su questo host una richiesta che deve essere accettata prima di consentire una connessione VNC in entrata." + }, + "window.title.approval": { + "message": "Richiesta di connessione" + }, + "approval.title.vnc": { + "message": "Consentire la connessione VNC?" + }, + "approval.title.ssh": { + "message": "Consentire la connessione SSH?" + }, + "approval.title.default": { + "message": "Consentire la connessione in entrata?" + }, + "approval.field.user": { + "message": "Dall'utente" + }, + "approval.field.keyFingerprint": { + "message": "Impronta della chiave" + }, + "approval.field.peer": { + "message": "Tramite peer" + }, + "approval.field.sourceIp": { + "message": "IP di origine" + }, + "approval.field.osUser": { + "message": "Utente del sistema" + }, + "approval.countdown": { + "message": "Rifiuto automatico tra {seconds}s" + }, + "approval.action.allow": { + "message": "Consenti" + }, + "approval.action.allowViewOnly": { + "message": "Consenti (sola visualizzazione)" + }, + "approval.action.deny": { + "message": "Rifiuta" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 2ed0a94c592..46415a0fc35 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "A operação falhou." + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "Servidor" + }, + "settings.vnc.section.approval": { + "message": "Aprovação" + }, + "settings.vnc.server.label": { + "message": "Ativar servidor VNC" + }, + "settings.vnc.server.help": { + "message": "Execute o servidor VNC do NetBird neste host para que os peers autorizados possam ver ou controlar a sua tela." + }, + "settings.vnc.approval.label": { + "message": "Exigir aprovação de conexão" + }, + "settings.vnc.approval.help": { + "message": "Mostrar neste host um aviso que precisa ser aceito antes de permitir uma conexão VNC de entrada." + }, + "window.title.approval": { + "message": "Solicitação de conexão" + }, + "approval.title.vnc": { + "message": "Permitir a conexão VNC?" + }, + "approval.title.ssh": { + "message": "Permitir a conexão SSH?" + }, + "approval.title.default": { + "message": "Permitir a conexão de entrada?" + }, + "approval.field.user": { + "message": "Do usuário" + }, + "approval.field.keyFingerprint": { + "message": "Impressão digital da chave" + }, + "approval.field.peer": { + "message": "Via peer" + }, + "approval.field.sourceIp": { + "message": "IP de origem" + }, + "approval.field.osUser": { + "message": "Usuário do SO" + }, + "approval.countdown": { + "message": "Negação automática em {seconds}s" + }, + "approval.action.allow": { + "message": "Permitir" + }, + "approval.action.allowViewOnly": { + "message": "Permitir (somente visualização)" + }, + "approval.action.deny": { + "message": "Negar" } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 6ba7de8cc52..ec8d3fdda96 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "Не удалось выполнить операцию." + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "Сервер" + }, + "settings.vnc.section.approval": { + "message": "Подтверждение" + }, + "settings.vnc.server.label": { + "message": "Включить VNC-сервер" + }, + "settings.vnc.server.help": { + "message": "Запустить VNC-сервер NetBird на этом хосте, чтобы авторизованные пиры могли просматривать его экран или управлять им." + }, + "settings.vnc.approval.label": { + "message": "Требовать подтверждение подключения" + }, + "settings.vnc.approval.help": { + "message": "Показывать на этом хосте запрос, который нужно принять перед разрешением входящего VNC-подключения." + }, + "window.title.approval": { + "message": "Запрос на подключение" + }, + "approval.title.vnc": { + "message": "Разрешить VNC-подключение?" + }, + "approval.title.ssh": { + "message": "Разрешить SSH-подключение?" + }, + "approval.title.default": { + "message": "Разрешить входящее подключение?" + }, + "approval.field.user": { + "message": "От пользователя" + }, + "approval.field.keyFingerprint": { + "message": "Отпечаток ключа" + }, + "approval.field.peer": { + "message": "Через пир" + }, + "approval.field.sourceIp": { + "message": "IP-адрес источника" + }, + "approval.field.osUser": { + "message": "Пользователь ОС" + }, + "approval.countdown": { + "message": "Автоотклонение через {seconds} с" + }, + "approval.action.allow": { + "message": "Разрешить" + }, + "approval.action.allowViewOnly": { + "message": "Разрешить (только просмотр)" + }, + "approval.action.deny": { + "message": "Отклонить" } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 609344fc047..2ca9cbf5427 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1321,5 +1321,65 @@ }, "error.unknown": { "message": "操作失败。" + }, + "settings.tabs.vnc": { + "message": "VNC" + }, + "settings.vnc.section.server": { + "message": "服务器" + }, + "settings.vnc.section.approval": { + "message": "批准" + }, + "settings.vnc.server.label": { + "message": "启用 VNC 服务器" + }, + "settings.vnc.server.help": { + "message": "在此主机上运行 NetBird VNC 服务器,以便授权的对端可以查看或控制其屏幕。" + }, + "settings.vnc.approval.label": { + "message": "要求连接批准" + }, + "settings.vnc.approval.help": { + "message": "在此主机上显示一个提示,必须先接受该提示才能允许传入的 VNC 连接。" + }, + "window.title.approval": { + "message": "连接请求" + }, + "approval.title.vnc": { + "message": "允许 VNC 连接?" + }, + "approval.title.ssh": { + "message": "允许 SSH 连接?" + }, + "approval.title.default": { + "message": "允许传入连接?" + }, + "approval.field.user": { + "message": "来自用户" + }, + "approval.field.keyFingerprint": { + "message": "密钥指纹" + }, + "approval.field.peer": { + "message": "经由对端" + }, + "approval.field.sourceIp": { + "message": "源 IP" + }, + "approval.field.osUser": { + "message": "操作系统用户" + }, + "approval.countdown": { + "message": "{seconds} 秒后自动拒绝" + }, + "approval.action.allow": { + "message": "允许" + }, + "approval.action.allowViewOnly": { + "message": "允许(仅查看)" + }, + "approval.action.deny": { + "message": "拒绝" } } diff --git a/client/ui/main.go b/client/ui/main.go index e6b77762cd3..79b0240cf6a 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -318,6 +318,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(s.settings)) app.RegisterService(application.NewService(s.networks)) app.RegisterService(application.NewService(services.NewForwarding(conn))) + app.RegisterService(application.NewService(services.NewApproval(conn))) app.RegisterService(application.NewService(s.profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) app.RegisterService(application.NewService(s.update)) diff --git a/client/ui/services/approval.go b/client/ui/services/approval.go new file mode 100644 index 00000000000..0dbf648afbd --- /dev/null +++ b/client/ui/services/approval.go @@ -0,0 +1,36 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +// Approval forwards the user's decision on a pending inbound-connection +// approval prompt to the daemon. The daemon pushes the prompt as a SystemEvent +// with category APPROVAL; the dialog calls Respond with the same request id to +// unblock whichever subsystem (VNC, SSH, ...) is waiting. +type Approval struct { + conn DaemonConn +} + +func NewApproval(conn DaemonConn) *Approval { + return &Approval{conn: conn} +} + +// Respond delivers the accept/deny decision for requestID. viewOnly is only +// meaningful when accept is true and the subsystem supports a read-only grant. +func (a *Approval) Respond(ctx context.Context, requestID string, accept, viewOnly bool) error { + cli, err := a.conn.Client() + if err != nil { + return err + } + _, err = cli.RespondApproval(ctx, &proto.RespondApprovalRequest{ + RequestId: requestID, + Accept: accept, + ViewOnly: viewOnly, + }) + return err +} diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 1c16795ae83..9e1be7d4a2e 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -24,7 +24,7 @@ type MDMFields struct { DisableMetricsCollection bool `json:"disableMetricsCollection"` SplitTunnelMode bool `json:"splitTunnelMode"` SplitTunnelApps bool `json:"splitTunnelApps"` - DisableAdvancedView bool `json:"disableAdvancedView"` + DisableAdvancedView bool `json:"disableAdvancedView"` } type Features struct { @@ -54,6 +54,8 @@ type Config struct { MTU int64 `json:"mtu"` DisableAutoConnect bool `json:"disableAutoConnect"` ServerSSHAllowed bool `json:"serverSshAllowed"` + ServerVNCAllowed bool `json:"serverVncAllowed"` + DisableVNCApproval bool `json:"disableVncApproval"` RosenpassEnabled bool `json:"rosenpassEnabled"` RosenpassPermissive bool `json:"rosenpassPermissive"` DisableNotifications bool `json:"disableNotifications"` @@ -85,6 +87,8 @@ type SetConfigParams struct { PreSharedKey *string `json:"preSharedKey,omitempty"` DisableAutoConnect *bool `json:"disableAutoConnect,omitempty"` ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"` + ServerVNCAllowed *bool `json:"serverVncAllowed,omitempty"` + DisableVNCApproval *bool `json:"disableVncApproval,omitempty"` RosenpassEnabled *bool `json:"rosenpassEnabled,omitempty"` RosenpassPermissive *bool `json:"rosenpassPermissive,omitempty"` DisableNotifications *bool `json:"disableNotifications,omitempty"` @@ -135,6 +139,8 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error MTU: resp.GetMtu(), DisableAutoConnect: resp.GetDisableAutoConnect(), ServerSSHAllowed: resp.GetServerSSHAllowed(), + ServerVNCAllowed: resp.GetServerVNCAllowed(), + DisableVNCApproval: resp.GetDisableVNCApproval(), RosenpassEnabled: resp.GetRosenpassEnabled(), RosenpassPermissive: resp.GetRosenpassPermissive(), DisableNotifications: resp.GetDisableNotifications(), @@ -170,6 +176,8 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { OptionalPreSharedKey: p.PreSharedKey, DisableAutoConnect: p.DisableAutoConnect, ServerSSHAllowed: p.ServerSSHAllowed, + ServerVNCAllowed: p.ServerVNCAllowed, + DisableVNCApproval: p.DisableVNCApproval, RosenpassEnabled: p.RosenpassEnabled, RosenpassPermissive: p.RosenpassPermissive, DisableNotifications: p.DisableNotifications, diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 3316dadaad6..4a7e65e4d73 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -106,6 +106,7 @@ type WindowManager struct { settings *application.WebviewWindow browserLogin *application.WebviewWindow sessionExpiration *application.WebviewWindow + approval *application.WebviewWindow installProgress *application.WebviewWindow welcome *application.WebviewWindow errorDialog *application.WebviewWindow @@ -279,6 +280,58 @@ func (s *WindowManager) CloseSessionExpiration() { } } +// ApprovalRequest carries the daemon-supplied metadata for an inbound-connection +// approval prompt to the dialog window as query params. Kind, RequestID and +// ExpiresAt are daemon-issued; the rest are remote-influenced and shown so the +// user can vet who is connecting. +type ApprovalRequest struct { + RequestID string + Kind string + Initiator string + PeerName string + SourceIP string + Username string + PeerPubKey string + ExpiresAt string +} + +// OpenApproval shows the inbound-connection approval prompt on the cursor's +// display. Singleton, destroyed on close: a second request replaces the window, +// and the superseded request auto-denies on the daemon's deadline. +func (s *WindowManager) OpenApproval(req ApprovalRequest) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := approvalDialogURL(req) + if s.approval == nil { + opts := DialogWindowOptions("approval", s.title("window.title.approval"), startURL, s.linuxIcon) + opts.Height = 380 + opts.Screen = s.getScreenBasedOnCursorPosition() + opts.InitialPosition = application.WindowCentered + s.approval = s.app.Window.NewWithOptions(opts) + s.approval.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.approval = nil + s.mu.Unlock() + }) + s.centerOnCursorScreen(s.approval) + return + } + s.approval.SetURL(startURL) + s.centerOnCursorScreen(s.approval) + s.approval.Show() + s.approval.Focus() +} + +func (s *WindowManager) CloseApproval() { + s.mu.Lock() + w := s.approval + s.approval = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + // OpenInstallProgress shows the install-progress window and hides the rest for the duration // (restored on close). It owns its own result polling since the daemon restarts mid-install. func (s *WindowManager) OpenInstallProgress(version string) { @@ -572,5 +625,30 @@ func errorDialogURL(title, message string) string { return startURL } +// approvalDialogURL builds the approval window's start URL with the request +// metadata as escaped query params. Empty fields are omitted so the dialog +// renders only the rows it has values for. +func approvalDialogURL(req ApprovalRequest) string { + q := url.Values{} + set := func(k, v string) { + if v != "" { + q.Set(k, v) + } + } + set("request_id", req.RequestID) + set("kind", req.Kind) + set("initiator", req.Initiator) + set("peer_name", req.PeerName) + set("source_ip", req.SourceIP) + set("username", req.Username) + set("peer_pubkey", req.PeerPubKey) + set("expires_at", req.ExpiresAt) + startURL := "/#/dialog/approval" + if enc := q.Encode(); enc != "" { + startURL += "?" + enc + } + return startURL +} + // u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields. func u32ptr(v uint32) *uint32 { return &v } diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 12da68a5c1f..0855cd58c84 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -42,6 +42,14 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { } return } + // Inbound-connection approval prompts open a dedicated dialog instead of a + // toast. Handle before the message gate: the daemon auto-denies on its + // deadline, so a missing WindowManager fails closed. + if se.Category == "approval" { + t.openApproval(se) + return + } + // Session-warning and deadline-rejected events build their body locally from // metadata; every other event needs a UserMessage. isSessionWarning := se.Metadata[authsession.MetaWarning] == "true" @@ -93,6 +101,30 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { t.notify(eventTitle(se), body, notifyIDEvent+se.ID) } +// openApproval opens the inbound-connection approval dialog from an APPROVAL +// SystemEvent. request_id is daemon-issued; without it the prompt can't be +// answered, so it's dropped and the daemon auto-denies on its deadline. +func (t *Tray) openApproval(se services.SystemEvent) { + if t.svc.WindowManager == nil { + return + } + requestID := se.Metadata["request_id"] + if requestID == "" { + log.Warnf("approval event missing request_id: %v", se.Metadata) + return + } + t.svc.WindowManager.OpenApproval(services.ApprovalRequest{ + RequestID: requestID, + Kind: se.Metadata["kind"], + Initiator: se.Metadata["initiator"], + PeerName: se.Metadata["peer_name"], + SourceIP: se.Metadata["source_ip"], + Username: se.Metadata["username"], + PeerPubKey: se.Metadata["peer_pubkey"], + ExpiresAt: se.Metadata["expires_at"], + }) +} + // eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication". func eventTitle(e services.SystemEvent) string { prefix := titleCase(e.Severity) From cec9ea8c0030662479d5c3f402ceba46ce046cc8 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 12 Jul 2026 18:46:47 +0200 Subject: [PATCH 095/151] Support IPv6 for the embedded VNC server and browser proxy --- client/internal/engine_vnc.go | 23 +++++++ client/vnc/server/server.go | 85 +++++++++++++++++++------ client/vnc/server/server_darwin.go | 12 ++-- client/vnc/server/server_test.go | 48 ++++++++++++-- client/vnc/server/server_windows.go | 12 ++-- client/vnc/server/server_x11.go | 6 +- client/wasm/cmd/main.go | 23 +++---- client/wasm/internal/netutil/network.go | 16 +++++ client/wasm/internal/ssh/client.go | 9 +-- client/wasm/internal/vnc/proxy.go | 14 +++- 10 files changed, 184 insertions(+), 64 deletions(-) create mode 100644 client/wasm/internal/netutil/network.go diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 7aff50f90da..aa2fd7a507a 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -24,6 +24,7 @@ import ( type vncServer interface { Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error + AddListener(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error Stop() error ActiveSessions() []vncserver.ActiveSessionInfo } @@ -43,6 +44,15 @@ func (e *Engine) setupVNCPortRedirection() error { } log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vnc.ExternalPort, localAddr, vnc.InternalPort) + if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() { + v6 := wgAddr.IPv6 + if err := e.firewall.AddInboundDNAT(v6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil { + log.Warnf("failed to add IPv6 VNC port redirection: %v", err) + } else { + log.Infof("VNC port redirection: [%s]:%d -> [%s]:%d", v6, vnc.ExternalPort, v6, vnc.InternalPort) + } + } + return nil } @@ -60,6 +70,12 @@ func (e *Engine) cleanupVNCPortRedirection() error { return fmt.Errorf("remove VNC port redirection: %w", err) } + if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() { + if err := e.firewall.RemoveInboundDNAT(wgAddr.IPv6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil { + log.Debugf("failed to remove IPv6 VNC port redirection: %v", err) + } + } + return nil } @@ -134,6 +150,13 @@ func (e *Engine) startVNCServer() error { return fmt.Errorf("start VNC server: %w", err) } + if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() { + v6Addr := netip.AddrPortFrom(wgAddr.IPv6, vnc.InternalPort) + if err := srv.AddListener(e.ctx, v6Addr, wgAddr.IPv6Net); err != nil { + log.Warnf("failed to add IPv6 VNC listener: %v", err) + } + } + e.vncSrv = srv if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 3ed69d90952..6f45812208e 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -169,15 +169,22 @@ type Server struct { localAddr netip.Addr // network is the NetBird overlay network. network netip.Prefix - log *log.Entry - - mu sync.Mutex - listener net.Listener - ctx context.Context - cancel context.CancelFunc - vmgr virtualSessionManager - authorizer *sshauth.Authorizer - netstackNet *netstack.Net + // localAddr6 and network6 are the v6 overlay address and network, set + // when a v6 listener is added; zero when the overlay has no v6. + localAddr6 netip.Addr + network6 netip.Prefix + log *log.Entry + + mu sync.Mutex + listener net.Listener + // extraListeners holds additional listeners (e.g. the v6 overlay), closed + // alongside listener on Stop. + extraListeners []net.Listener + ctx context.Context + cancel context.CancelFunc + vmgr virtualSessionManager + authorizer *sshauth.Authorizer + netstackNet *netstack.Net // agentToken holds the raw token bytes for agent-mode auth. agentToken []byte // invalidAgentToken latches when AgentTokenHex was provided but failed @@ -609,21 +616,54 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.P } if s.serviceMode { - go s.serviceAcceptLoop() + go s.serviceAcceptLoop(s.listener) } else { - go s.acceptLoop() + go s.acceptLoop(s.listener) } s.log.Infof("started on %s (service_mode=%v)", listenDesc, s.serviceMode) return nil } +// AddListener opens an additional overlay listener (e.g. the v6 overlay +// address) and serves it with the same accept path as the primary listener. +// The server must already be running. Mirrors the primary listener's mode so +// service-mode connections still route through the per-session agent proxy. +func (s *Server) AddListener(_ context.Context, addr netip.AddrPort, network netip.Prefix) error { + s.mu.Lock() + if s.listener == nil { + s.mu.Unlock() + return fmt.Errorf("server not running") + } + ln, desc, err := s.openOverlayListener(addr, network) + if err != nil { + s.mu.Unlock() + return err + } + s.extraListeners = append(s.extraListeners, ln) + serviceMode := s.serviceMode + s.mu.Unlock() + + s.log.Infof("also listening on %s (service_mode=%v)", desc, serviceMode) + if serviceMode { + go s.serviceAcceptLoop(ln) + } else { + go s.acceptLoop(ln) + } + return nil +} + func (s *Server) openOverlayListener(addr netip.AddrPort, network netip.Prefix) (net.Listener, string, error) { if !network.IsValid() { return nil, "", fmt.Errorf("invalid overlay network prefix") } - s.localAddr = addr.Addr() - s.network = network + if addr.Addr().Is6() { + s.localAddr6 = addr.Addr() + s.network6 = network + } else { + s.localAddr = addr.Addr() + s.network = network + } if s.netstackNet != nil { ln, err := s.netstackNet.ListenTCPAddrPort(addr) if err != nil { @@ -658,6 +698,12 @@ func (s *Server) Stop() error { listenerErr = s.listener.Close() s.listener = nil } + for _, ln := range s.extraListeners { + if err := ln.Close(); err != nil && listenerErr == nil { + listenerErr = err + } + } + s.extraListeners = nil s.closeActiveSessions() if s.vmgr != nil { @@ -681,10 +727,7 @@ func (s *Server) Stop() error { } // acceptLoop handles VNC connections directly (user session mode). -func (s *Server) acceptLoop() { - s.mu.Lock() - ln := s.listener - s.mu.Unlock() +func (s *Server) acceptLoop(ln net.Listener) { if ln == nil { return } @@ -790,16 +833,18 @@ func (s *Server) isAllowedSource(addr net.Addr) bool { return true } - if remoteIP == s.localAddr { + if remoteIP == s.localAddr || (s.localAddr6.IsValid() && remoteIP == s.localAddr6) { s.log.Warnf("connection rejected from own IP %s", remoteIP) return false } - if !s.network.IsValid() { + if !s.network.IsValid() && !s.network6.IsValid() { s.log.Warnf("connection rejected: overlay network not configured") return false } - if !s.network.Contains(remoteIP) { + inV4 := s.network.IsValid() && s.network.Contains(remoteIP) + inV6 := s.network6.IsValid() && s.network6.Contains(remoteIP) + if !inV4 && !inV6 { s.log.Warnf("connection rejected from non-NetBird IP %s", remoteIP) return false } diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 593de3e799d..1924c3556fd 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -24,18 +24,16 @@ func (s *Server) platformSessionManager() virtualSessionManager { // to the per-user agent darwinAgentManager spawns via launchctl asuser // (the only spawn mode that lands a child in the user's Aqua session with // WindowServer + TCC access). -func (s *Server) serviceAcceptLoop() { +func (s *Server) serviceAcceptLoop(ln net.Listener) { + if ln == nil { + return + } + mgr := newDarwinAgentManager(s.ctx) defer mgr.stop() log.Info("service mode, proxying connections to per-user agent over Unix socket") - s.mu.Lock() - ln := s.listener - s.mu.Unlock() - if ln == nil { - return - } for { conn, err := ln.Accept() if err != nil { diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index fa42ea81a6e..10b7e196a83 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -145,11 +145,13 @@ func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { func TestIsAllowedSource(t *testing.T) { tests := []struct { - name string - localAddr netip.Addr - network netip.Prefix - remote net.Addr - want bool + name string + localAddr netip.Addr + network netip.Prefix + localAddr6 netip.Addr + network6 netip.Prefix + remote net.Addr + want bool }{ { // Unix-domain remotes (per-session agent path) are local IPC, @@ -202,12 +204,48 @@ func TestIsAllowedSource(t *testing.T) { remote: &net.TCPAddr{IP: net.ParseIP("10.99.99.2"), Port: 5900}, want: false, }, + { + name: "v6 overlay IP allowed", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + localAddr6: netip.MustParseAddr("fd00:1234::1"), + network6: netip.MustParsePrefix("fd00:1234::/64"), + remote: &net.TCPAddr{IP: net.ParseIP("fd00:1234::2"), Port: 5900}, + want: true, + }, + { + name: "v6 own IP rejected", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + localAddr6: netip.MustParseAddr("fd00:1234::1"), + network6: netip.MustParsePrefix("fd00:1234::/64"), + remote: &net.TCPAddr{IP: net.ParseIP("fd00:1234::1"), Port: 5900}, + want: false, + }, + { + name: "v6 outside overlay rejected", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + localAddr6: netip.MustParseAddr("fd00:1234::1"), + network6: netip.MustParsePrefix("fd00:1234::/64"), + remote: &net.TCPAddr{IP: net.ParseIP("2001:db8::5"), Port: 5900}, + want: false, + }, + { + name: "v6 rejected when only v4 overlay configured", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + remote: &net.TCPAddr{IP: net.ParseIP("fd00:1234::2"), Port: 5900}, + want: false, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { srv := New(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}}) srv.localAddr = tc.localAddr srv.network = tc.network + srv.localAddr6 = tc.localAddr6 + srv.network6 = tc.network6 assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote)) }) } diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 88ebf74f80f..74ef18407e9 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -241,20 +241,16 @@ func (s *Server) platformInit() { // serviceAcceptLoop runs in Session 0. It validates the source IP and // hands accepted connections to handleServiceConnection, which runs the // Noise_IK handshake before proxying to the user-session agent. -func (s *Server) serviceAcceptLoop() { +func (s *Server) serviceAcceptLoop(ln net.Listener) { + if ln == nil { + return + } sm := newSessionManager() go sm.run() log.Info("service mode, proxying connections to agent over Unix socket") - s.mu.Lock() - ln := s.listener - s.mu.Unlock() - if ln == nil { - sm.Stop() - return - } for { conn, err := ln.Accept() if err != nil { diff --git a/client/vnc/server/server_x11.go b/client/vnc/server/server_x11.go index 6c0b6b643e8..e1084641069 100644 --- a/client/vnc/server/server_x11.go +++ b/client/vnc/server/server_x11.go @@ -2,14 +2,16 @@ package server +import "net" + func (s *Server) platformInit() { // no-op on X11 } // serviceAcceptLoop is not supported on Linux. -func (s *Server) serviceAcceptLoop() { +func (s *Server) serviceAcceptLoop(ln net.Listener) { s.log.Warn("service mode not supported on Linux, falling back to direct mode") - s.acceptLoop() + s.acceptLoop(ln) } func (s *Server) platformSessionManager() virtualSessionManager { diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 611d0e8b279..e58d94ab1df 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -20,6 +20,7 @@ import ( nbstatus "github.com/netbirdio/netbird/client/status" wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture" "github.com/netbirdio/netbird/client/wasm/internal/http" + "github.com/netbirdio/netbird/client/wasm/internal/netutil" "github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/ssh" "github.com/netbirdio/netbird/client/wasm/internal/vnc" @@ -264,7 +265,7 @@ func performPingTCP(client *netbird.Client, hostname string, port, ipVersion int ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) defer cancel() - network := ipVersionNetwork("tcp", ipVersion) + network := netutil.TCPNetwork(ipVersion) address := net.JoinHostPort(hostname, fmt.Sprintf("%d", port)) start := time.Now() @@ -410,7 +411,7 @@ func createGenerateVNCSessionKeyMethod() js.Func { } // createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging. -// JS signature: createVNCProxy(hostname, port, mode?, username?, keySessionID?, sessionID?, width?, height?, peerPublicKey?) +// JS signature: createVNCProxy(hostname, port, mode?, username?, keySessionID?, sessionID?, width?, height?, peerPublicKey?, ipVersion?) // // mode: "attach" (default) or "session" // username: required when mode is "session" @@ -418,6 +419,7 @@ func createGenerateVNCSessionKeyMethod() js.Func { // sessionID: Windows session ID (0 = console/auto) // width/height: requested viewport size for session mode (0 = server default) // peerPublicKey: base64 X25519 static pubkey of the destination peer (required for auth) +// ipVersion: address family to dial: 4, 6, or 0/omitted for automatic func createVNCProxyMethod(client *netbird.Client) js.Func { return js.FuncOf(func(_ js.Value, args []js.Value) any { params, err := parseVNCProxyArgs(args) @@ -440,6 +442,7 @@ func createVNCProxyMethod(client *netbird.Client) js.Func { Height: params.height, PeerPublicKey: params.peerPublicKey, KeySessionID: params.keySessionID, + IPVersion: params.ipVersion, }) }) } @@ -454,6 +457,7 @@ type vncProxyParams struct { width uint16 height uint16 peerPublicKey string + ipVersion int rejectViaPromise bool } @@ -540,6 +544,9 @@ func parseVNCProxyOptionalNumbers(args []js.Value, p *vncProxyParams) error { if len(args) > 8 && args[8].Type() == js.TypeString { p.peerPublicKey = args[8].String() } + if len(args) > 9 { + p.ipVersion = jsIPVersion(args[9]) + } return nil } @@ -662,18 +669,6 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func { }) } -// ipVersionNetwork appends "4" or "6" to a base network string (e.g. "tcp" -> "tcp4"). -func ipVersionNetwork(base string, ipVersion int) string { - switch ipVersion { - case 4: - return base + "4" - case 6: - return base + "6" - default: - return base - } -} - // jsIPVersion extracts an IP version (4 or 6) from a JS string or number. func jsIPVersion(v js.Value) int { switch v.Type() { diff --git a/client/wasm/internal/netutil/network.go b/client/wasm/internal/netutil/network.go new file mode 100644 index 00000000000..c802756e858 --- /dev/null +++ b/client/wasm/internal/netutil/network.go @@ -0,0 +1,16 @@ +// Package netutil holds small networking helpers shared across the wasm +// client's proxy paths (SSH, VNC, ping). +package netutil + +// TCPNetwork maps an IP-version selector to the net package's TCP network +// string: 4 -> "tcp4", 6 -> "tcp6", anything else (0/automatic) -> "tcp". +func TCPNetwork(ipVersion int) string { + switch ipVersion { + case 4: + return "tcp4" + case 6: + return "tcp6" + default: + return "tcp" + } +} diff --git a/client/wasm/internal/ssh/client.go b/client/wasm/internal/ssh/client.go index 9cfe652669c..80ef4844958 100644 --- a/client/wasm/internal/ssh/client.go +++ b/client/wasm/internal/ssh/client.go @@ -15,6 +15,7 @@ import ( netbird "github.com/netbirdio/netbird/client/embed" nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/wasm/internal/netutil" ) const ( @@ -64,13 +65,7 @@ func (c *Client) Connect(host string, port int, username, jwtToken string, ipVer Timeout: sshDialTimeout, } - network := "tcp" - switch ipVersion { - case 4: - network = "tcp4" - case 6: - network = "tcp6" - } + network := netutil.TCPNetwork(ipVersion) ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout) defer cancel() diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 5df541edf6e..fa3472782aa 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -17,6 +17,8 @@ import ( "github.com/flynn/noise" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/wasm/internal/netutil" ) var cryptoRandRead = crand.Read @@ -133,6 +135,7 @@ type VNCProxy struct { type vncDestination struct { address string + network string mode byte username string sessionPriv []byte @@ -188,6 +191,10 @@ type ProxyRequest struct { // matching private key is looked up inside wasm and never crosses // the JS boundary. KeySessionID string + // IPVersion selects the address family for the dial to the destination: + // 4, 6, or 0 for automatic selection. Mirrors the SSH proxy so the + // dashboard can resolve a peer label to a specific family. + IPVersion int } // CreateProxy creates a new proxy endpoint for the given VNC destination. @@ -207,6 +214,7 @@ func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { dest := vncDestination{ address: address, + network: netutil.TCPNetwork(req.IPVersion), mode: m, username: username, sessionID: sessionID, @@ -394,7 +402,11 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) { ctx, cancel := context.WithTimeout(conn.ctx, vncDialTimeout) defer cancel() - vncConn, err := p.nbClient.Dial(ctx, "tcp", conn.destination.address) + network := conn.destination.network + if network == "" { + network = "tcp" + } + vncConn, err := p.nbClient.Dial(ctx, network, conn.destination.address) if err != nil { log.Errorf("VNC connect to %s: %v", conn.destination.address, err) // Close the WebSocket so noVNC fires a disconnect event. From 125250c5df16a922f24fe850559fd1c46b4cfaf2 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 13 Jul 2026 00:06:33 +0200 Subject: [PATCH 096/151] Regenerate proto gateway and OpenAPI code with pinned tool versions --- client/proto/daemon.pb.gw.go | 63 +++++ shared/management/http/api/types.gen.go | 12 +- shared/management/proto/management_grpc.pb.go | 232 ++++++++++-------- 3 files changed, 197 insertions(+), 110 deletions(-) diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go index b64dfeea1a6..fd0c5b9fed8 100644 --- a/client/proto/daemon.pb.gw.go +++ b/client/proto/daemon.pb.gw.go @@ -1099,6 +1099,30 @@ func request_DaemonService_ExposeService_0(ctx context.Context, marshaler runtim return stream, metadata, nil } +func request_DaemonService_RespondApproval_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RespondApprovalRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RespondApproval(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RespondApproval_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RespondApprovalRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RespondApproval(ctx, &protoReq) + return msg, metadata, err +} + func request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq WailsUIReadyRequest @@ -1977,6 +2001,26 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return }) + mux.Handle(http.MethodPost, pattern_DaemonService_RespondApproval_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RespondApproval", runtime.WithHTTPPathPattern("/daemon.DaemonService/RespondApproval")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RespondApproval_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RespondApproval_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2802,6 +2846,23 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM } forward_DaemonService_ExposeService_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_DaemonService_RespondApproval_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RespondApproval", runtime.WithHTTPPathPattern("/daemon.DaemonService/RespondApproval")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RespondApproval_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RespondApproval_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2868,6 +2929,7 @@ var ( pattern_DaemonService_StopCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopCPUProfile"}, "")) pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, "")) pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, "")) + pattern_DaemonService_RespondApproval_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RespondApproval"}, "")) pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, "")) ) @@ -2917,5 +2979,6 @@ var ( forward_DaemonService_StopCPUProfile_0 = runtime.ForwardResponseMessage forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream + forward_DaemonService_RespondApproval_0 = runtime.ForwardResponseMessage forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage ) diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 09ae56f92bc..d42756f0549 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1,6 +1,6 @@ // Package api provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.6.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. package api import ( @@ -13,8 +13,8 @@ import ( ) const ( - BearerAuthScopes = "BearerAuth.Scopes" - TokenAuthScopes = "TokenAuth.Scopes" + BearerAuthScopes bearerAuthContextKey = "BearerAuth.Scopes" + TokenAuthScopes tokenAuthContextKey = "TokenAuth.Scopes" ) // Defines values for AccessRestrictionsCrowdsecMode. @@ -5717,6 +5717,12 @@ type ZoneRequest struct { // Conflict Standard error response. Note: The exact structure of this error response is inferred from `util.WriteErrorResponse` and `util.WriteError` usage in the provided Go code, as a specific Go struct for errors was not provided. type Conflict = ErrorResponse +// bearerAuthContextKey is the context key for BearerAuth security scheme +type bearerAuthContextKey string + +// tokenAuthContextKey is the context key for TokenAuth security scheme +type tokenAuthContextKey string + // GetApiAgentNetworkAccessLogSessionsParams defines parameters for GetApiAgentNetworkAccessLogSessions. type GetApiAgentNetworkAccessLogSessionsParams struct { // Page Page number for pagination (1-indexed). diff --git a/shared/management/proto/management_grpc.pb.go b/shared/management/proto/management_grpc.pb.go index 94c9767a055..ce98e4019a7 100644 --- a/shared/management/proto/management_grpc.pb.go +++ b/shared/management/proto/management_grpc.pb.go @@ -1,8 +1,4 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.1 -// - protoc v7.34.1 -// source: management.proto package proto @@ -15,24 +11,8 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - ManagementService_Login_FullMethodName = "/management.ManagementService/Login" - ManagementService_Sync_FullMethodName = "/management.ManagementService/Sync" - ManagementService_GetServerKey_FullMethodName = "/management.ManagementService/GetServerKey" - ManagementService_IsHealthy_FullMethodName = "/management.ManagementService/isHealthy" - ManagementService_GetDeviceAuthorizationFlow_FullMethodName = "/management.ManagementService/GetDeviceAuthorizationFlow" - ManagementService_GetPKCEAuthorizationFlow_FullMethodName = "/management.ManagementService/GetPKCEAuthorizationFlow" - ManagementService_SyncMeta_FullMethodName = "/management.ManagementService/SyncMeta" - ManagementService_Logout_FullMethodName = "/management.ManagementService/Logout" - ManagementService_Job_FullMethodName = "/management.ManagementService/Job" - ManagementService_ExtendAuthSession_FullMethodName = "/management.ManagementService/ExtendAuthSession" - ManagementService_CreateExpose_FullMethodName = "/management.ManagementService/CreateExpose" - ManagementService_RenewExpose_FullMethodName = "/management.ManagementService/RenewExpose" - ManagementService_StopExpose_FullMethodName = "/management.ManagementService/StopExpose" -) +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 // ManagementServiceClient is the client API for ManagementService service. // @@ -45,7 +25,7 @@ type ManagementServiceClient interface { // For example, if a new peer has been added to an account all other connected peers will receive this peer's Wireguard public key as an update // The initial SyncResponse contains all of the available peers so the local state can be refreshed // Returns encrypted SyncResponse in EncryptedMessage.Body - Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (grpc.ServerStreamingClient[EncryptedMessage], error) + Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (ManagementService_SyncClient, error) // Exposes a Wireguard public key of the Management service. // This key is used to support message encryption between client and server GetServerKey(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ServerKeyResponse, error) @@ -71,7 +51,7 @@ type ManagementServiceClient interface { // Logout logs out the peer and removes it from the management server Logout(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error) // Executes a job on a target peer (e.g., debug bundle) - Job(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage], error) + Job(ctx context.Context, opts ...grpc.CallOption) (ManagementService_JobClient, error) // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT. // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check), // but does not redo the network-map sync. Only valid for SSO-registered peers where @@ -96,22 +76,20 @@ func NewManagementServiceClient(cc grpc.ClientConnInterface) ManagementServiceCl } func (c *managementServiceClient) Login(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, ManagementService_Login_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/Login", in, out, opts...) if err != nil { return nil, err } return out, nil } -func (c *managementServiceClient) Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (grpc.ServerStreamingClient[EncryptedMessage], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[0], ManagementService_Sync_FullMethodName, cOpts...) +func (c *managementServiceClient) Sync(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (ManagementService_SyncClient, error) { + stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[0], "/management.ManagementService/Sync", opts...) if err != nil { return nil, err } - x := &grpc.GenericClientStream[EncryptedMessage, EncryptedMessage]{ClientStream: stream} + x := &managementServiceSyncClient{stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -121,13 +99,26 @@ func (c *managementServiceClient) Sync(ctx context.Context, in *EncryptedMessage return x, nil } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ManagementService_SyncClient = grpc.ServerStreamingClient[EncryptedMessage] +type ManagementService_SyncClient interface { + Recv() (*EncryptedMessage, error) + grpc.ClientStream +} + +type managementServiceSyncClient struct { + grpc.ClientStream +} + +func (x *managementServiceSyncClient) Recv() (*EncryptedMessage, error) { + m := new(EncryptedMessage) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} func (c *managementServiceClient) GetServerKey(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ServerKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ServerKeyResponse) - err := c.cc.Invoke(ctx, ManagementService_GetServerKey_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/GetServerKey", in, out, opts...) if err != nil { return nil, err } @@ -135,9 +126,8 @@ func (c *managementServiceClient) GetServerKey(ctx context.Context, in *Empty, o } func (c *managementServiceClient) IsHealthy(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) - err := c.cc.Invoke(ctx, ManagementService_IsHealthy_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/isHealthy", in, out, opts...) if err != nil { return nil, err } @@ -145,9 +135,8 @@ func (c *managementServiceClient) IsHealthy(ctx context.Context, in *Empty, opts } func (c *managementServiceClient) GetDeviceAuthorizationFlow(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, ManagementService_GetDeviceAuthorizationFlow_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/GetDeviceAuthorizationFlow", in, out, opts...) if err != nil { return nil, err } @@ -155,9 +144,8 @@ func (c *managementServiceClient) GetDeviceAuthorizationFlow(ctx context.Context } func (c *managementServiceClient) GetPKCEAuthorizationFlow(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, ManagementService_GetPKCEAuthorizationFlow_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/GetPKCEAuthorizationFlow", in, out, opts...) if err != nil { return nil, err } @@ -165,9 +153,8 @@ func (c *managementServiceClient) GetPKCEAuthorizationFlow(ctx context.Context, } func (c *managementServiceClient) SyncMeta(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) - err := c.cc.Invoke(ctx, ManagementService_SyncMeta_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/SyncMeta", in, out, opts...) if err != nil { return nil, err } @@ -175,32 +162,48 @@ func (c *managementServiceClient) SyncMeta(ctx context.Context, in *EncryptedMes } func (c *managementServiceClient) Logout(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) - err := c.cc.Invoke(ctx, ManagementService_Logout_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/Logout", in, out, opts...) if err != nil { return nil, err } return out, nil } -func (c *managementServiceClient) Job(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[1], ManagementService_Job_FullMethodName, cOpts...) +func (c *managementServiceClient) Job(ctx context.Context, opts ...grpc.CallOption) (ManagementService_JobClient, error) { + stream, err := c.cc.NewStream(ctx, &ManagementService_ServiceDesc.Streams[1], "/management.ManagementService/Job", opts...) if err != nil { return nil, err } - x := &grpc.GenericClientStream[EncryptedMessage, EncryptedMessage]{ClientStream: stream} + x := &managementServiceJobClient{stream} return x, nil } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ManagementService_JobClient = grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage] +type ManagementService_JobClient interface { + Send(*EncryptedMessage) error + Recv() (*EncryptedMessage, error) + grpc.ClientStream +} + +type managementServiceJobClient struct { + grpc.ClientStream +} + +func (x *managementServiceJobClient) Send(m *EncryptedMessage) error { + return x.ClientStream.SendMsg(m) +} + +func (x *managementServiceJobClient) Recv() (*EncryptedMessage, error) { + m := new(EncryptedMessage) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} func (c *managementServiceClient) ExtendAuthSession(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, ManagementService_ExtendAuthSession_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/ExtendAuthSession", in, out, opts...) if err != nil { return nil, err } @@ -208,9 +211,8 @@ func (c *managementServiceClient) ExtendAuthSession(ctx context.Context, in *Enc } func (c *managementServiceClient) CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, ManagementService_CreateExpose_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/CreateExpose", in, out, opts...) if err != nil { return nil, err } @@ -218,9 +220,8 @@ func (c *managementServiceClient) CreateExpose(ctx context.Context, in *Encrypte } func (c *managementServiceClient) RenewExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, ManagementService_RenewExpose_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/RenewExpose", in, out, opts...) if err != nil { return nil, err } @@ -228,9 +229,8 @@ func (c *managementServiceClient) RenewExpose(ctx context.Context, in *Encrypted } func (c *managementServiceClient) StopExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(EncryptedMessage) - err := c.cc.Invoke(ctx, ManagementService_StopExpose_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, "/management.ManagementService/StopExpose", in, out, opts...) if err != nil { return nil, err } @@ -239,7 +239,7 @@ func (c *managementServiceClient) StopExpose(ctx context.Context, in *EncryptedM // ManagementServiceServer is the server API for ManagementService service. // All implementations must embed UnimplementedManagementServiceServer -// for forward compatibility. +// for forward compatibility type ManagementServiceServer interface { // Login logs in peer. In case server returns codes.PermissionDenied this endpoint can be used to register Peer providing LoginRequest.setupKey // Returns encrypted LoginResponse in EncryptedMessage.Body @@ -248,7 +248,7 @@ type ManagementServiceServer interface { // For example, if a new peer has been added to an account all other connected peers will receive this peer's Wireguard public key as an update // The initial SyncResponse contains all of the available peers so the local state can be refreshed // Returns encrypted SyncResponse in EncryptedMessage.Body - Sync(*EncryptedMessage, grpc.ServerStreamingServer[EncryptedMessage]) error + Sync(*EncryptedMessage, ManagementService_SyncServer) error // Exposes a Wireguard public key of the Management service. // This key is used to support message encryption between client and server GetServerKey(context.Context, *Empty) (*ServerKeyResponse, error) @@ -274,7 +274,7 @@ type ManagementServiceServer interface { // Logout logs out the peer and removes it from the management server Logout(context.Context, *EncryptedMessage) (*Empty, error) // Executes a job on a target peer (e.g., debug bundle) - Job(grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage]) error + Job(ManagementService_JobServer) error // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT. // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check), // but does not redo the network-map sync. Only valid for SSO-registered peers where @@ -291,54 +291,50 @@ type ManagementServiceServer interface { mustEmbedUnimplementedManagementServiceServer() } -// UnimplementedManagementServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedManagementServiceServer struct{} +// UnimplementedManagementServiceServer must be embedded to have forward compatible implementations. +type UnimplementedManagementServiceServer struct { +} func (UnimplementedManagementServiceServer) Login(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Error(codes.Unimplemented, "method Login not implemented") + return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") } -func (UnimplementedManagementServiceServer) Sync(*EncryptedMessage, grpc.ServerStreamingServer[EncryptedMessage]) error { - return status.Error(codes.Unimplemented, "method Sync not implemented") +func (UnimplementedManagementServiceServer) Sync(*EncryptedMessage, ManagementService_SyncServer) error { + return status.Errorf(codes.Unimplemented, "method Sync not implemented") } func (UnimplementedManagementServiceServer) GetServerKey(context.Context, *Empty) (*ServerKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetServerKey not implemented") + return nil, status.Errorf(codes.Unimplemented, "method GetServerKey not implemented") } func (UnimplementedManagementServiceServer) IsHealthy(context.Context, *Empty) (*Empty, error) { - return nil, status.Error(codes.Unimplemented, "method IsHealthy not implemented") + return nil, status.Errorf(codes.Unimplemented, "method IsHealthy not implemented") } func (UnimplementedManagementServiceServer) GetDeviceAuthorizationFlow(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Error(codes.Unimplemented, "method GetDeviceAuthorizationFlow not implemented") + return nil, status.Errorf(codes.Unimplemented, "method GetDeviceAuthorizationFlow not implemented") } func (UnimplementedManagementServiceServer) GetPKCEAuthorizationFlow(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Error(codes.Unimplemented, "method GetPKCEAuthorizationFlow not implemented") + return nil, status.Errorf(codes.Unimplemented, "method GetPKCEAuthorizationFlow not implemented") } func (UnimplementedManagementServiceServer) SyncMeta(context.Context, *EncryptedMessage) (*Empty, error) { - return nil, status.Error(codes.Unimplemented, "method SyncMeta not implemented") + return nil, status.Errorf(codes.Unimplemented, "method SyncMeta not implemented") } func (UnimplementedManagementServiceServer) Logout(context.Context, *EncryptedMessage) (*Empty, error) { - return nil, status.Error(codes.Unimplemented, "method Logout not implemented") + return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented") } -func (UnimplementedManagementServiceServer) Job(grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage]) error { - return status.Error(codes.Unimplemented, "method Job not implemented") +func (UnimplementedManagementServiceServer) Job(ManagementService_JobServer) error { + return status.Errorf(codes.Unimplemented, "method Job not implemented") } func (UnimplementedManagementServiceServer) ExtendAuthSession(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Error(codes.Unimplemented, "method ExtendAuthSession not implemented") + return nil, status.Errorf(codes.Unimplemented, "method ExtendAuthSession not implemented") } func (UnimplementedManagementServiceServer) CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Error(codes.Unimplemented, "method CreateExpose not implemented") + return nil, status.Errorf(codes.Unimplemented, "method CreateExpose not implemented") } func (UnimplementedManagementServiceServer) RenewExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Error(codes.Unimplemented, "method RenewExpose not implemented") + return nil, status.Errorf(codes.Unimplemented, "method RenewExpose not implemented") } func (UnimplementedManagementServiceServer) StopExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { - return nil, status.Error(codes.Unimplemented, "method StopExpose not implemented") + return nil, status.Errorf(codes.Unimplemented, "method StopExpose not implemented") } func (UnimplementedManagementServiceServer) mustEmbedUnimplementedManagementServiceServer() {} -func (UnimplementedManagementServiceServer) testEmbeddedByValue() {} // UnsafeManagementServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ManagementServiceServer will @@ -348,13 +344,6 @@ type UnsafeManagementServiceServer interface { } func RegisterManagementServiceServer(s grpc.ServiceRegistrar, srv ManagementServiceServer) { - // If the following call panics, it indicates UnimplementedManagementServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } s.RegisterService(&ManagementService_ServiceDesc, srv) } @@ -368,7 +357,7 @@ func _ManagementService_Login_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_Login_FullMethodName, + FullMethod: "/management.ManagementService/Login", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).Login(ctx, req.(*EncryptedMessage)) @@ -381,11 +370,21 @@ func _ManagementService_Sync_Handler(srv interface{}, stream grpc.ServerStream) if err := stream.RecvMsg(m); err != nil { return err } - return srv.(ManagementServiceServer).Sync(m, &grpc.GenericServerStream[EncryptedMessage, EncryptedMessage]{ServerStream: stream}) + return srv.(ManagementServiceServer).Sync(m, &managementServiceSyncServer{stream}) } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ManagementService_SyncServer = grpc.ServerStreamingServer[EncryptedMessage] +type ManagementService_SyncServer interface { + Send(*EncryptedMessage) error + grpc.ServerStream +} + +type managementServiceSyncServer struct { + grpc.ServerStream +} + +func (x *managementServiceSyncServer) Send(m *EncryptedMessage) error { + return x.ServerStream.SendMsg(m) +} func _ManagementService_GetServerKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Empty) @@ -397,7 +396,7 @@ func _ManagementService_GetServerKey_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_GetServerKey_FullMethodName, + FullMethod: "/management.ManagementService/GetServerKey", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).GetServerKey(ctx, req.(*Empty)) @@ -415,7 +414,7 @@ func _ManagementService_IsHealthy_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_IsHealthy_FullMethodName, + FullMethod: "/management.ManagementService/isHealthy", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).IsHealthy(ctx, req.(*Empty)) @@ -433,7 +432,7 @@ func _ManagementService_GetDeviceAuthorizationFlow_Handler(srv interface{}, ctx } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_GetDeviceAuthorizationFlow_FullMethodName, + FullMethod: "/management.ManagementService/GetDeviceAuthorizationFlow", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).GetDeviceAuthorizationFlow(ctx, req.(*EncryptedMessage)) @@ -451,7 +450,7 @@ func _ManagementService_GetPKCEAuthorizationFlow_Handler(srv interface{}, ctx co } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_GetPKCEAuthorizationFlow_FullMethodName, + FullMethod: "/management.ManagementService/GetPKCEAuthorizationFlow", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).GetPKCEAuthorizationFlow(ctx, req.(*EncryptedMessage)) @@ -469,7 +468,7 @@ func _ManagementService_SyncMeta_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_SyncMeta_FullMethodName, + FullMethod: "/management.ManagementService/SyncMeta", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).SyncMeta(ctx, req.(*EncryptedMessage)) @@ -487,7 +486,7 @@ func _ManagementService_Logout_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_Logout_FullMethodName, + FullMethod: "/management.ManagementService/Logout", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).Logout(ctx, req.(*EncryptedMessage)) @@ -496,11 +495,30 @@ func _ManagementService_Logout_Handler(srv interface{}, ctx context.Context, dec } func _ManagementService_Job_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(ManagementServiceServer).Job(&grpc.GenericServerStream[EncryptedMessage, EncryptedMessage]{ServerStream: stream}) + return srv.(ManagementServiceServer).Job(&managementServiceJobServer{stream}) +} + +type ManagementService_JobServer interface { + Send(*EncryptedMessage) error + Recv() (*EncryptedMessage, error) + grpc.ServerStream +} + +type managementServiceJobServer struct { + grpc.ServerStream +} + +func (x *managementServiceJobServer) Send(m *EncryptedMessage) error { + return x.ServerStream.SendMsg(m) } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ManagementService_JobServer = grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage] +func (x *managementServiceJobServer) Recv() (*EncryptedMessage, error) { + m := new(EncryptedMessage) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} func _ManagementService_ExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(EncryptedMessage) @@ -512,7 +530,7 @@ func _ManagementService_ExtendAuthSession_Handler(srv interface{}, ctx context.C } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_ExtendAuthSession_FullMethodName, + FullMethod: "/management.ManagementService/ExtendAuthSession", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).ExtendAuthSession(ctx, req.(*EncryptedMessage)) @@ -530,7 +548,7 @@ func _ManagementService_CreateExpose_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_CreateExpose_FullMethodName, + FullMethod: "/management.ManagementService/CreateExpose", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).CreateExpose(ctx, req.(*EncryptedMessage)) @@ -548,7 +566,7 @@ func _ManagementService_RenewExpose_Handler(srv interface{}, ctx context.Context } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_RenewExpose_FullMethodName, + FullMethod: "/management.ManagementService/RenewExpose", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).RenewExpose(ctx, req.(*EncryptedMessage)) @@ -566,7 +584,7 @@ func _ManagementService_StopExpose_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ManagementService_StopExpose_FullMethodName, + FullMethod: "/management.ManagementService/StopExpose", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ManagementServiceServer).StopExpose(ctx, req.(*EncryptedMessage)) From eb6e8dc9056650d77c80d8444803deb507885162 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 13 Jul 2026 11:52:50 +0200 Subject: [PATCH 097/151] Reject empty approval request_id and verify gid after setgid --- client/cmd/vnc_agent_dropprivs_darwin.go | 3 +++ client/server/server.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/client/cmd/vnc_agent_dropprivs_darwin.go b/client/cmd/vnc_agent_dropprivs_darwin.go index d61e33747be..ae8cc028198 100644 --- a/client/cmd/vnc_agent_dropprivs_darwin.go +++ b/client/cmd/vnc_agent_dropprivs_darwin.go @@ -49,6 +49,9 @@ func dropAgentPrivileges(targetUID uint32) error { if err := syscall.Setgid(targetGID); err != nil { return fmt.Errorf("setgid(%d): %w", targetGID, err) } + if os.Getgid() != targetGID || os.Getegid() != targetGID { + return fmt.Errorf("setgid verification: gid=%d egid=%d, expected %d", os.Getgid(), os.Getegid(), targetGID) + } if err := syscall.Setuid(int(targetUID)); err != nil { return fmt.Errorf("setuid(%d): %w", targetUID, err) } diff --git a/client/server/server.go b/client/server/server.go index a3ff1e2efea..7cc9bbd7dcd 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1906,6 +1906,9 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon // request_ids are silently no-op'd so a slow UI cannot deny a prompt the // user already handled (or that already timed out). func (s *Server) RespondApproval(_ context.Context, msg *proto.RespondApprovalRequest) (*proto.RespondApprovalResponse, error) { + if msg.GetRequestId() == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "request_id is required") + } s.mutex.Lock() connectClient := s.connectClient s.mutex.Unlock() From 152ba28d9f6bd0b05c40a6f5b5f88cac049b79f0 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Mon, 13 Jul 2026 16:39:28 +0200 Subject: [PATCH 098/151] Add VNC allow and approval settings to MDM policy --- client/internal/profilemanager/config.go | 2 + .../profilemanager/config_mdm_test.go | 30 +++++++++++++ client/mdm/canonical_loaders.go | 2 + client/mdm/policy.go | 10 +++-- client/server/mdm.go | 8 ++++ client/server/setconfig_mdm_test.go | 24 ++++++++++ .../modules/settings/SettingsNavigation.tsx | 3 +- .../src/modules/settings/SettingsPage.tsx | 9 +++- .../src/modules/settings/SettingsVNC.tsx | 25 +++++++---- client/ui/services/settings.go | 6 +++ client/ui/services/settings_mdm_test.go | 44 +++++++++++++++++++ docs/io.netbird.client.plist | 6 +++ docs/netbird-macos.mobileconfig | 4 ++ docs/netbird-macos.sh | 4 ++ docs/netbird.adml | 4 ++ docs/netbird.admx | 24 ++++++++++ 16 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 client/ui/services/settings_mdm_test.go diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 953b3564a63..8bc61914675 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -735,6 +735,8 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { } applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv }) + applyBool(mdm.KeyAllowServerVNC, func(v bool) { bv := v; config.ServerVNCAllowed = &bv }) + applyBool(mdm.KeyDisableVNCApproval, func(v bool) { bv := v; config.DisableVNCApproval = &bv }) applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v }) applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v }) applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v }) diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index c6a688ab286..eef4955596d 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -130,6 +130,36 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) } +func TestApply_MDMVNCKeys(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM: VNC off, approval prompt on. + withMDMPolicy(t, mdm.NewPolicy(nil)) + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: tmp, + ServerVNCAllowed: boolPtr(false), + DisableVNCApproval: boolPtr(false), + }) + require.NoError(t, err) + + // MDM enforces VNC on and disables the approval prompt. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyAllowServerVNC: true, + mdm.KeyDisableVNCApproval: true, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.NotNil(t, cfg.ServerVNCAllowed) + assert.True(t, *cfg.ServerVNCAllowed, "MDM override should flip on-disk false to true") + require.NotNil(t, cfg.DisableVNCApproval) + assert.True(t, *cfg.DisableVNCApproval) + assert.True(t, cfg.Policy().HasKey(mdm.KeyAllowServerVNC)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableVNCApproval)) +} + func TestApply_MDMLazyConnection(t *testing.T) { cases := []struct { name string diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index cb9af9ccba7..6c06d5f9423 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -21,6 +21,8 @@ var allKeys = []string{ KeyBlockInbound, KeyDisableMetricsCollection, KeyAllowServerSSH, + KeyAllowServerVNC, + KeyDisableVNCApproval, KeyDisableAutoConnect, KeyPreSharedKey, KeyRosenpassEnabled, diff --git a/client/mdm/policy.go b/client/mdm/policy.go index b76c70a7562..e9b2faa0a56 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -20,10 +20,10 @@ import ( // names (lowerCamelCase) so the daemon can map a Policy key directly to a // configuration field. const ( - KeyManagementURL = "managementURL" - KeyDisableUpdateSettings = "disableUpdateSettings" - KeyDisableProfiles = "disableProfiles" - KeyDisableNetworks = "disableNetworks" + KeyManagementURL = "managementURL" + KeyDisableUpdateSettings = "disableUpdateSettings" + KeyDisableProfiles = "disableProfiles" + KeyDisableNetworks = "disableNetworks" // KeyDisableAdvancedView gates the advanced-view section in the // upcoming UI revision. UI-only: NOT stored on Config, not // applied by applyMDMPolicy, not rejectable via SetConfig. The @@ -36,6 +36,8 @@ const ( KeyBlockInbound = "blockInbound" KeyDisableMetricsCollection = "disableMetricsCollection" KeyAllowServerSSH = "allowServerSSH" + KeyAllowServerVNC = "allowServerVNC" + KeyDisableVNCApproval = "disableVNCApproval" KeyDisableAutoConnect = "disableAutoConnect" KeyPreSharedKey = "preSharedKey" KeyRosenpassEnabled = "rosenpassEnabled" diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea8d..b82aa8712d6 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -297,6 +297,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyAllowServerVNC, msg.ServerVNCAllowed), + conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), @@ -332,6 +334,8 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.Mtu != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.ServerVNCAllowed != nil || + msg.DisableVNCApproval != nil || msg.NetworkMonitor != nil || msg.DisableClientRoutes != nil || msg.DisableServerRoutes != nil || @@ -370,6 +374,8 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.WireguardPort != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.ServerVNCAllowed != nil || + msg.DisableVNCApproval != nil || msg.RosenpassPermissive != nil || len(msg.ExtraIFaceBlacklist) > 0 || msg.NetworkMonitor != nil || @@ -418,6 +424,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyAllowServerVNC, msg.ServerVNCAllowed), + conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 9baf161360e..420ec70b81a 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -105,6 +105,30 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields()) } +func TestSetConfig_MDMReject_VNCFields(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyAllowServerVNC: true, + mdm.KeyDisableVNCApproval: false, + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + vncAllowed := false + disableApproval := true + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ServerVNCAllowed: &vncAllowed, + DisableVNCApproval: &disableApproval, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyAllowServerVNC, + mdm.KeyDisableVNCApproval, + }, v.GetFields()) +} + func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { withMDMPolicy(t, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index b28e05fb045..5d639f72579 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -21,6 +21,7 @@ export const SettingsNavigation = () => { const { updateAvailable } = useClientVersion(); const { mdm, features } = useRestrictions(); const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings; + const showVnc = mdm.allowServerVNC ?? !features.disableUpdateSettings; const aboutAdornment = updateAvailable ? ( @@ -64,7 +65,7 @@ export const SettingsNavigation = () => { title={t("settings.tabs.ssh")} /> )} - {!features.disableUpdateSettings && ( + {showVnc && ( { [Tab.Security]: editable, [Tab.Profiles]: !features.disableProfiles, [Tab.SSH]: mdm.allowServerSSH ?? editable, - [Tab.VNC]: editable, + [Tab.VNC]: mdm.allowServerVNC ?? editable, [Tab.Advanced]: editable, [Tab.Troubleshooting]: true, [Tab.About]: true, }; return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]); - }, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]); + }, [ + features.disableUpdateSettings, + features.disableProfiles, + mdm.allowServerSSH, + mdm.allowServerVNC, + ]); const defaultTab = visibleTabs[0]; const [active, setActive] = useState(() => navState?.tab ?? defaultTab); diff --git a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx index 1da4a2c26a9..78a6f3ad400 100644 --- a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx @@ -2,11 +2,14 @@ import { useTranslation } from "react-i18next"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; export function SettingsVNC() { const { t } = useTranslation(); const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); const isVNCServerEnabled = config.serverVncAllowed; + const vncServerManaged = mdm.allowServerVNC != null; return ( <> @@ -16,17 +19,23 @@ export function SettingsVNC() { onChange={(v) => setField("serverVncAllowed", v)} label={t("settings.vnc.server.label")} helpText={t("settings.vnc.server.help")} + disabled={vncServerManaged} /> - - setField("disableVncApproval", !v)} - label={t("settings.vnc.approval.label")} - helpText={t("settings.vnc.approval.help")} - /> - + {!mdm.disableVNCApproval && ( + + setField("disableVncApproval", !v)} + label={t("settings.vnc.approval.label")} + helpText={t("settings.vnc.approval.help")} + /> + + )} ); } diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 9e1be7d4a2e..7d1be3f28e6 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -19,6 +19,8 @@ type MDMFields struct { DisableClientRoutes bool `json:"disableClientRoutes"` DisableServerRoutes bool `json:"disableServerRoutes"` AllowServerSSH *bool `json:"allowServerSSH"` + AllowServerVNC *bool `json:"allowServerVNC"` + DisableVNCApproval bool `json:"disableVNCApproval"` DisableAutoConnect bool `json:"disableAutoConnect"` BlockInbound bool `json:"blockInbound"` DisableMetricsCollection bool `json:"disableMetricsCollection"` @@ -261,4 +263,8 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { allowed := cfgResp.GetServerSSHAllowed() mdm.AllowServerSSH = &allowed } + if _, ok := set["allowServerVNC"]; ok { + allowed := cfgResp.GetServerVNCAllowed() + mdm.AllowServerVNC = &allowed + } } diff --git a/client/ui/services/settings_mdm_test.go b/client/ui/services/settings_mdm_test.go new file mode 100644 index 00000000000..8821a227943 --- /dev/null +++ b/client/ui/services/settings_mdm_test.go @@ -0,0 +1,44 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +func TestApplyMDMRestrictions_VNCFields(t *testing.T) { + t.Run("unmanaged leaves fields at zero", func(t *testing.T) { + var mdm MDMFields + applyMDMRestrictions(&mdm, &proto.GetConfigResponse{}) + assert.Nil(t, mdm.AllowServerVNC) + assert.False(t, mdm.DisableVNCApproval) + }) + + t.Run("managed surfaces enforced values", func(t *testing.T) { + var mdm MDMFields + applyMDMRestrictions(&mdm, &proto.GetConfigResponse{ + MDMManagedFields: []string{"allowServerVNC", "disableVNCApproval"}, + ServerVNCAllowed: true, + DisableVNCApproval: true, + }) + require.NotNil(t, mdm.AllowServerVNC) + assert.True(t, *mdm.AllowServerVNC, "AllowServerVNC should carry the enforced value") + assert.True(t, mdm.DisableVNCApproval, "DisableVNCApproval should be flagged managed") + }) + + t.Run("managed VNC disallowed surfaces false", func(t *testing.T) { + var mdm MDMFields + applyMDMRestrictions(&mdm, &proto.GetConfigResponse{ + MDMManagedFields: []string{"allowServerVNC"}, + ServerVNCAllowed: false, + }) + require.NotNil(t, mdm.AllowServerVNC) + assert.False(t, *mdm.AllowServerVNC) + assert.False(t, mdm.DisableVNCApproval, "unmanaged approval stays zero") + }) +} diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index 800ecead187..79fb4b2fc4b 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -63,6 +63,12 @@