mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-31 21:19:55 +00:00
938 lines
31 KiB
Go
938 lines
31 KiB
Go
//go:build !js && !ios && !android
|
|
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/crypto/curve25519"
|
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
|
|
|
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
|
)
|
|
|
|
// 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 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"
|
|
RejectCodeApprovalDenied = "APPROVAL_DENIED"
|
|
RejectCodeNoApprover = "NO_APPROVER"
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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.
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
// 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.
|
|
// 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.
|
|
GetClipboard() string
|
|
// TypeText synthesizes the given text as keystrokes on the active
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
// 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.
|
|
// 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
|
|
serviceMode bool
|
|
disableAuth bool
|
|
// 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
|
|
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
|
|
// 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
|
|
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{}
|
|
// 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
|
|
// 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.
|
|
preListener net.Listener
|
|
}
|
|
|
|
// 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
|
|
// 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.
|
|
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()
|
|
}
|
|
|
|
// 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
|
|
IdentityKey []byte
|
|
ServiceMode bool
|
|
SessionRecorder func(SessionTick)
|
|
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
|
|
// 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
|
|
// 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: cfg.Capturer,
|
|
injector: cfg.Injector,
|
|
identityKey: cfg.IdentityKey,
|
|
serviceMode: cfg.ServiceMode,
|
|
sessionRecorder: cfg.SessionRecorder,
|
|
requireApproval: cfg.RequireApproval,
|
|
approver: cfg.Approver,
|
|
disableAuth: cfg.DisableAuth,
|
|
netstackNet: cfg.NetstackNet,
|
|
preListener: cfg.Listener,
|
|
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
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
|
|
func (s *Server) removeSession(id uint64) {
|
|
s.sessionsMu.Lock()
|
|
defer s.sessionsMu.Unlock()
|
|
delete(s.sessions, id)
|
|
delete(s.sessionConns, id)
|
|
}
|
|
|
|
// 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.acceptedConns))
|
|
for c := range s.acceptedConns {
|
|
conns = append(conns, c)
|
|
}
|
|
s.sessionsMu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
delete(s.connAuth, c)
|
|
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).
|
|
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()
|
|
}
|
|
}
|
|
|
|
// 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.authorizer.Update(config)
|
|
s.revokeUnauthorizedSessions()
|
|
}
|
|
|
|
// 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()
|
|
|
|
if s.listener != nil {
|
|
return fmt.Errorf("server already running")
|
|
}
|
|
|
|
s.ctx, s.cancel = context.WithCancel(ctx)
|
|
s.vmgr = s.platformSessionManager()
|
|
|
|
var listenDesc string
|
|
switch {
|
|
case s.preListener != nil:
|
|
s.listener = s.preListener
|
|
listenDesc = s.preListener.Addr().String()
|
|
default:
|
|
ln, desc, err := s.openOverlayListener(addr, network)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.listener = ln
|
|
listenDesc = desc
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
s.cancel = nil
|
|
}
|
|
|
|
// 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 {
|
|
s.vmgr.StopAll()
|
|
}
|
|
|
|
if s.serviceMode {
|
|
s.platformShutdown()
|
|
}
|
|
|
|
if c, ok := s.capturer.(interface{ Close() }); ok {
|
|
c.Close()
|
|
}
|
|
|
|
if listenerErr != nil {
|
|
return fmt.Errorf("close VNC listener: %w", listenerErr)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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 {
|
|
// Unix-socket remotes (the agent path) are local IPC, gated by the
|
|
// token, not by overlay membership.
|
|
tcpAddr, ok := addr.(*net.TCPAddr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
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) {
|
|
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
|
|
}
|
|
ok, agentViewOnly := s.verifyAgentToken(conn, connLog)
|
|
if !ok {
|
|
connLog.Info("VNC connection rejected: agent token check failed")
|
|
return
|
|
}
|
|
header, err := s.readConnectionHeader(conn)
|
|
if err != nil {
|
|
connLog.Infof("VNC connection rejected: header read failed: %v", err)
|
|
_ = conn.Close()
|
|
return
|
|
}
|
|
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")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
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("VNC connection rejected: framebuffer %dx%d outside [1, %d]", w, h, maxFramebufferDim)
|
|
return
|
|
}
|
|
|
|
conn = newMetricsConn(conn, s.sessionRecorder)
|
|
sess := &session{
|
|
conn: conn,
|
|
capturer: capturer,
|
|
injector: injector,
|
|
serverW: w,
|
|
serverH: h,
|
|
log: connLog,
|
|
viewOnly: decision.ViewOnly || agentViewOnly,
|
|
}
|
|
sess.serve()
|
|
connLog.Infof("VNC connection closed (%dms)", time.Since(start).Milliseconds())
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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:
|
|
capturer, cleanup := s.acquireAttachSession()
|
|
return capturer, s.injector, cleanup, 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
|
|
}
|
|
|
|
// 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, func() { /* capturer has no per-client disconnect hook */ }
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|