mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 08:16:39 +00:00
Add embedded VNC server with JWT auth, DXGI capture, and dashboard integration
This commit is contained in:
@@ -150,6 +150,7 @@ func init() {
|
||||
rootCmd.AddCommand(logoutCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(sshCmd)
|
||||
rootCmd.AddCommand(vncCmd)
|
||||
rootCmd.AddCommand(networksCMD)
|
||||
rootCmd.AddCommand(forwardingRulesCmd)
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
|
||||
@@ -36,7 +36,10 @@ const (
|
||||
enableSSHLocalPortForwardFlag = "enable-ssh-local-port-forwarding"
|
||||
enableSSHRemotePortForwardFlag = "enable-ssh-remote-port-forwarding"
|
||||
disableSSHAuthFlag = "disable-ssh-auth"
|
||||
sshJWTCacheTTLFlag = "ssh-jwt-cache-ttl"
|
||||
jwtCacheTTLFlag = "jwt-cache-ttl"
|
||||
|
||||
// Alias for backward compatibility.
|
||||
sshJWTCacheTTLFlag = "ssh-jwt-cache-ttl"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -61,7 +64,7 @@ var (
|
||||
enableSSHLocalPortForward bool
|
||||
enableSSHRemotePortForward bool
|
||||
disableSSHAuth bool
|
||||
sshJWTCacheTTL int
|
||||
jwtCacheTTL int
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -71,7 +74,9 @@ func init() {
|
||||
upCmd.PersistentFlags().BoolVar(&enableSSHLocalPortForward, enableSSHLocalPortForwardFlag, false, "Enable local port forwarding for SSH server")
|
||||
upCmd.PersistentFlags().BoolVar(&enableSSHRemotePortForward, enableSSHRemotePortForwardFlag, false, "Enable remote port forwarding for SSH server")
|
||||
upCmd.PersistentFlags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "Disable SSH authentication")
|
||||
upCmd.PersistentFlags().IntVar(&sshJWTCacheTTL, sshJWTCacheTTLFlag, 0, "SSH JWT token cache TTL in seconds (0=disabled)")
|
||||
upCmd.PersistentFlags().IntVar(&jwtCacheTTL, jwtCacheTTLFlag, 0, "JWT token cache TTL in seconds (0=disabled)")
|
||||
upCmd.PersistentFlags().IntVar(&jwtCacheTTL, sshJWTCacheTTLFlag, 0, "JWT token cache TTL in seconds (alias for --jwt-cache-ttl)")
|
||||
_ = upCmd.PersistentFlags().MarkDeprecated(sshJWTCacheTTLFlag, "use --jwt-cache-ttl instead")
|
||||
|
||||
sshCmd.PersistentFlags().IntVarP(&port, "port", "p", sshserver.DefaultSSHPort, "Remote SSH port")
|
||||
sshCmd.PersistentFlags().StringVarP(&username, "user", "u", "", sshUsernameDesc)
|
||||
|
||||
@@ -356,6 +356,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
|
||||
}
|
||||
@@ -371,9 +374,12 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
req.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||
req.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||
if cmd.Flag(disableVNCAuthFlag).Changed {
|
||||
req.DisableVNCAuth = &disableVNCAuth
|
||||
}
|
||||
if cmd.Flag(jwtCacheTTLFlag).Changed || cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
jwtCacheTTL32 := int32(jwtCacheTTL)
|
||||
req.SshJWTCacheTTL = &jwtCacheTTL32
|
||||
}
|
||||
if cmd.Flag(interfaceNameFlag).Changed {
|
||||
if err := parseInterfaceName(interfaceName); err != nil {
|
||||
@@ -458,6 +464,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
|
||||
@@ -479,8 +488,12 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
ic.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
||||
if cmd.Flag(disableVNCAuthFlag).Changed {
|
||||
ic.DisableVNCAuth = &disableVNCAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(jwtCacheTTLFlag).Changed || cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ic.SSHJWTCacheTTL = &jwtCacheTTL
|
||||
}
|
||||
|
||||
if cmd.Flag(interfaceNameFlag).Changed {
|
||||
@@ -582,6 +595,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
|
||||
@@ -603,9 +619,13 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||
loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||
if cmd.Flag(disableVNCAuthFlag).Changed {
|
||||
loginRequest.DisableVNCAuth = &disableVNCAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(jwtCacheTTLFlag).Changed || cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
jwtCacheTTL32 := int32(jwtCacheTTL)
|
||||
loginRequest.SshJWTCacheTTL = &jwtCacheTTL32
|
||||
}
|
||||
|
||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||
|
||||
271
client/cmd/vnc.go
Normal file
271
client/cmd/vnc.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
var (
|
||||
vncUsername string
|
||||
vncHost string
|
||||
vncMode string
|
||||
vncListen string
|
||||
vncNoBrowser bool
|
||||
vncNoCache bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
vncCmd.PersistentFlags().StringVar(&vncUsername, "user", "", "OS username for session mode")
|
||||
vncCmd.PersistentFlags().StringVar(&vncMode, "mode", "attach", "Connection mode: attach (view current display) or session (virtual desktop)")
|
||||
vncCmd.PersistentFlags().StringVar(&vncListen, "listen", "", "Start local VNC proxy on this address (e.g., :5900) for external VNC viewers")
|
||||
vncCmd.PersistentFlags().BoolVar(&vncNoBrowser, noBrowserFlag, false, noBrowserDesc)
|
||||
vncCmd.PersistentFlags().BoolVar(&vncNoCache, "no-cache", false, "Skip cached JWT token and force fresh authentication")
|
||||
}
|
||||
|
||||
var vncCmd = &cobra.Command{
|
||||
Use: "vnc [flags] [user@]host",
|
||||
Short: "Connect to a NetBird peer via VNC",
|
||||
Long: `Connect to a NetBird peer using VNC with JWT-based authentication.
|
||||
The target peer must have the VNC server enabled.
|
||||
|
||||
Two modes are available:
|
||||
- attach: view the current physical display (remote support)
|
||||
- session: start a virtual desktop as the specified user (passwordless login)
|
||||
|
||||
Use --listen to start a local proxy for external VNC viewers:
|
||||
netbird vnc --listen :5900 peer-hostname
|
||||
vncviewer localhost:5900
|
||||
|
||||
Examples:
|
||||
netbird vnc peer-hostname
|
||||
netbird vnc --mode session --user alice peer-hostname
|
||||
netbird vnc --listen :5900 peer-hostname`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: vncFn,
|
||||
}
|
||||
|
||||
func vncFn(cmd *cobra.Command, args []string) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
SetFlagsFromEnvVars(cmd)
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
|
||||
logOutput := "console"
|
||||
if firstLogFile := util.FindFirstLogPath(logFiles); firstLogFile != "" && firstLogFile != defaultLogFile {
|
||||
logOutput = firstLogFile
|
||||
}
|
||||
if err := util.InitLog(logLevel, logOutput); err != nil {
|
||||
return fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
|
||||
if err := parseVNCHostArg(args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := internal.CtxInitState(cmd.Context())
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
||||
vncCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := runVNC(vncCtx, cmd); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
cancel()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-sig:
|
||||
cancel()
|
||||
<-vncCtx.Done()
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-vncCtx.Done():
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseVNCHostArg(arg string) error {
|
||||
if strings.Contains(arg, "@") {
|
||||
parts := strings.SplitN(arg, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return fmt.Errorf("invalid user@host format")
|
||||
}
|
||||
if vncUsername == "" {
|
||||
vncUsername = parts[0]
|
||||
}
|
||||
vncHost = parts[1]
|
||||
if vncMode == "attach" {
|
||||
vncMode = "session"
|
||||
}
|
||||
} else {
|
||||
vncHost = arg
|
||||
}
|
||||
|
||||
if vncMode == "session" && vncUsername == "" {
|
||||
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
|
||||
vncUsername = sudoUser
|
||||
} else if currentUser, err := user.Current(); err == nil {
|
||||
vncUsername = currentUser.Username
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runVNC(ctx context.Context, cmd *cobra.Command) error {
|
||||
grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://")
|
||||
grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to daemon: %w", err)
|
||||
}
|
||||
defer func() { _ = grpcConn.Close() }()
|
||||
|
||||
daemonClient := proto.NewDaemonServiceClient(grpcConn)
|
||||
|
||||
if vncMode == "session" {
|
||||
cmd.Printf("Connecting to %s@%s [session mode]...\n", vncUsername, vncHost)
|
||||
} else {
|
||||
cmd.Printf("Connecting to %s [attach mode]...\n", vncHost)
|
||||
}
|
||||
|
||||
// Obtain JWT token. If the daemon has no SSO configured, proceed without one
|
||||
// (the server will accept unauthenticated connections if --disable-vnc-auth is set).
|
||||
var jwtToken string
|
||||
hint := profilemanager.GetLoginHint()
|
||||
var browserOpener func(string) error
|
||||
if !vncNoBrowser {
|
||||
browserOpener = util.OpenBrowser
|
||||
}
|
||||
|
||||
token, err := nbssh.RequestJWTToken(ctx, daemonClient, nil, cmd.ErrOrStderr(), !vncNoCache, hint, browserOpener)
|
||||
if err != nil {
|
||||
log.Debugf("JWT authentication unavailable, connecting without token: %v", err)
|
||||
} else {
|
||||
jwtToken = token
|
||||
log.Debug("JWT authentication successful")
|
||||
}
|
||||
|
||||
// Connect to the VNC server on the standard port (5900). The peer's firewall
|
||||
// DNATs 5900 -> 25900 (internal), so both ports work on the overlay network.
|
||||
vncAddr := net.JoinHostPort(vncHost, "5900")
|
||||
vncConn, err := net.DialTimeout("tcp", vncAddr, vncDialTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to VNC at %s: %w", vncAddr, err)
|
||||
}
|
||||
defer vncConn.Close()
|
||||
|
||||
// Send session header with mode, username, and JWT.
|
||||
if err := sendVNCHeader(vncConn, vncMode, vncUsername, jwtToken); err != nil {
|
||||
return fmt.Errorf("send VNC header: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("VNC connected to %s\n", vncHost)
|
||||
|
||||
if vncListen != "" {
|
||||
return runVNCLocalProxy(ctx, cmd, vncConn)
|
||||
}
|
||||
|
||||
// No --listen flag: inform the user they need to use --listen for external viewers.
|
||||
cmd.Printf("VNC tunnel established. Use --listen :5900 to proxy for local VNC viewers.\n")
|
||||
cmd.Printf("Press Ctrl+C to disconnect.\n")
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
const vncDialTimeout = 15 * time.Second
|
||||
|
||||
// sendVNCHeader writes the NetBird VNC session header.
|
||||
func sendVNCHeader(conn net.Conn, mode, username, jwt string) error {
|
||||
var modeByte byte
|
||||
if mode == "session" {
|
||||
modeByte = 1
|
||||
}
|
||||
|
||||
usernameBytes := []byte(username)
|
||||
jwtBytes := []byte(jwt)
|
||||
hdr := make([]byte, 3+len(usernameBytes)+2+len(jwtBytes))
|
||||
hdr[0] = modeByte
|
||||
binary.BigEndian.PutUint16(hdr[1:3], uint16(len(usernameBytes)))
|
||||
off := 3
|
||||
copy(hdr[off:], usernameBytes)
|
||||
off += len(usernameBytes)
|
||||
binary.BigEndian.PutUint16(hdr[off:off+2], uint16(len(jwtBytes)))
|
||||
off += 2
|
||||
copy(hdr[off:], jwtBytes)
|
||||
|
||||
_, err := conn.Write(hdr)
|
||||
return err
|
||||
}
|
||||
|
||||
// runVNCLocalProxy listens on the given address and proxies incoming
|
||||
// connections to the already-established VNC tunnel.
|
||||
func runVNCLocalProxy(ctx context.Context, cmd *cobra.Command, vncConn net.Conn) error {
|
||||
listener, err := net.Listen("tcp", vncListen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", vncListen, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
cmd.Printf("VNC proxy listening on %s - connect with your VNC viewer\n", listener.Addr())
|
||||
cmd.Printf("Press Ctrl+C to stop.\n")
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
listener.Close()
|
||||
}()
|
||||
|
||||
// Accept a single viewer connection. VNC is single-session: the RFB
|
||||
// handshake completes on vncConn for the first viewer, so subsequent
|
||||
// viewers would get a mid-stream connection. The loop handles transient
|
||||
// accept errors until a valid connection arrives.
|
||||
for {
|
||||
clientConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
log.Debugf("accept VNC proxy client: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
cmd.Printf("VNC viewer connected from %s\n", clientConn.RemoteAddr())
|
||||
|
||||
// Bidirectional copy.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
io.Copy(vncConn, clientConn)
|
||||
close(done)
|
||||
}()
|
||||
io.Copy(clientConn, vncConn)
|
||||
<-done
|
||||
clientConn.Close()
|
||||
|
||||
cmd.Printf("VNC viewer disconnected\n")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
62
client/cmd/vnc_agent.go
Normal file
62
client/cmd/vnc_agent.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"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)
|
||||
|
||||
capturer := vncserver.NewDesktopCapturer()
|
||||
injector := vncserver.NewWindowsInputInjector()
|
||||
srv := vncserver.New(capturer, injector, "")
|
||||
// Auth is handled by the service. The agent verifies a token on each
|
||||
// connection to ensure only the service process can connect.
|
||||
// The token is passed via environment variable to avoid exposing it
|
||||
// in the process command line (visible via tasklist/wmic).
|
||||
srv.SetDisableAuth(true)
|
||||
srv.SetAgentToken(os.Getenv("NB_VNC_AGENT_TOKEN"))
|
||||
|
||||
port, err := netip.ParseAddrPort("127.0.0.1:" + vncAgentPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loopback := netip.PrefixFrom(netip.AddrFrom4([4]byte{127, 0, 0, 0}), 8)
|
||||
if err := srv.Start(cmd.Context(), port, loopback); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
<-cmd.Context().Done()
|
||||
return srv.Stop()
|
||||
},
|
||||
}
|
||||
16
client/cmd/vnc_flags.go
Normal file
16
client/cmd/vnc_flags.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
serverVNCAllowedFlag = "allow-server-vnc"
|
||||
disableVNCAuthFlag = "disable-vnc-auth"
|
||||
)
|
||||
|
||||
var (
|
||||
serverVNCAllowed bool
|
||||
disableVNCAuth bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer")
|
||||
upCmd.PersistentFlags().BoolVar(&disableVNCAuth, disableVNCAuthFlag, false, "Disable JWT authentication for VNC")
|
||||
}
|
||||
Reference in New Issue
Block a user