diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go index 4c8d7cd55..b36206380 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 d5cb69277..08b73a25e 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 9592e3a75..c253de2ed 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 143bc8177..b102cf63e 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 30760efe1..6b88e891a 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 aba3e7da5..7bfb988de 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 8afbad110..11600bded 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 4c31aae21..a19fbcdc2 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 1a486c5e6..09314f438 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 }