feat: add VNC support with noVNC integration

- Introduced a new VNC viewer panel in the frontend, allowing users to connect to VNC sessions.
- Implemented VNC connection handling with optional SSH tunneling in the backend.
- Added a new SSH key manager for managing SSH keys within the terminal.
- Created a trigger rules engine to monitor terminal output and trigger actions based on defined patterns.
- Added CSS styles for the VNC viewer and its components.
- Updated database schema to support VNC protocol in profiles.
- Implemented WebSocket proxy for VNC connections to facilitate communication between the frontend and backend.
This commit is contained in:
Andy Dixon
2026-05-21 12:52:57 +01:00
parent 523d9b02b4
commit 96fedb9348
26 changed files with 3162 additions and 315 deletions
+120
View File
@@ -7,6 +7,8 @@ import (
"context"
"encoding/base64"
"fmt"
"net"
"os"
"strings"
"sync"
"time"
@@ -24,6 +26,7 @@ import (
"github.com/andydixon/nexus/internal/sshclient"
"github.com/andydixon/nexus/internal/storage"
"github.com/andydixon/nexus/internal/telnetclient"
"github.com/andydixon/nexus/internal/vncclient"
)
// Session wraps a protocol client with metadata and state.
@@ -34,6 +37,7 @@ type Session struct {
rawClient *rawclient.Client
githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording
@@ -123,6 +127,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
err = m.connectRAW(sess)
case models.ProtocolGitHub:
err = m.connectGitHub(sess, getSecret)
case models.ProtocolVNC:
err = m.connectVNC(sess, getSecret)
default:
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
}
@@ -867,6 +873,116 @@ func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref s
return nil
}
// connectVNC establishes a VNC connection (with optional SSH tunnel) and
// emits the local WebSocket proxy URL to the frontend.
func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (string, error)) error {
profile := sess.profile
vncConf := profile.VNC
if vncConf == nil {
return fmt.Errorf("VNC configuration missing")
}
// Fall back to the profile-level host if VNC-specific host is empty.
vncHost := vncConf.VNCHost
if vncHost == "" {
vncHost = profile.Host
}
cfg := vncclient.Config{
VNCHost: vncHost,
VNCPort: vncConf.VNCPort,
OnError: func(err error) {
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sess.Info.ID,
"state": string(models.SessionError),
"error": err.Error(),
})
},
}
// SSH tunnel configuration
if vncConf.SSHTunnel {
cfg.SSHTunnel = true
cfg.SSHHost = vncConf.SSHHost
cfg.SSHPort = vncConf.SSHPort
cfg.SSHUsername = vncConf.SSHUsername
cfg.SSHAuthMethod = vncConf.SSHAuthMethod
cfg.SSHAgentAuth = vncConf.SSHAgentAuth
// Resolve SSH credentials
switch vncConf.SSHAuthMethod {
case "password":
getPass := m.buildSecretCallback(profile.ID, "vnc-ssh-password", vncConf.SSHSecretMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("SSH password: %w", err)
}
cfg.SSHPassword = password
case "publickey":
if vncConf.SSHPrivateKey != "" {
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
if err != nil {
return fmt.Errorf("reading SSH key: %w", err)
}
cfg.SSHPrivateKey = keyData
// Get passphrase if needed
getPassphrase := m.buildSecretCallback(profile.ID, "vnc-ssh-passphrase", vncConf.SSHSecretMode, getSecret)
passphrase, _ := getPassphrase()
cfg.SSHPassphrase = passphrase
}
}
// SSH agent support
if vncConf.SSHAgentAuth {
socketPath := os.Getenv("SSH_AUTH_SOCK")
if socketPath != "" {
agentConn, err := net.Dial("unix", socketPath)
if err == nil {
cfg.SSHAgentConn = agentConn
}
}
}
} else {
// Direct VNC — may need a VNC password
getVNCPass := m.buildSecretCallback(profile.ID, "vnc-password", vncConf.SecretMode, getSecret)
password, _ := getVNCPass()
// VNC auth is handled by the RFB protocol in the frontend (noVNC),
// but we store it in case we need server-side auth negotiation later.
_ = password
}
client := vncclient.NewClient(cfg)
wsURL, err := client.Connect(cfg)
if err != nil {
return err
}
sess.vncClient = client
// Emit the WebSocket URL so the frontend can connect noVNC.
m.emitEvent("session:vnc:ready", map[string]interface{}{
"sessionID": sess.Info.ID,
"wsURL": wsURL,
})
return nil
}
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return "", fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.vncClient == nil {
return "", fmt.Errorf("sessions: session %q is not a VNC connection", sessionID)
}
return sess.vncClient.WSURL(), nil
}
// ---------------------------------------------------------------------------
// Session recording
// ---------------------------------------------------------------------------
@@ -1370,6 +1486,10 @@ func (m *Manager) closeSession(sess *Session) {
if sess.githubCancel != nil {
sess.githubCancel()
}
if sess.vncClient != nil {
sess.vncClient.Close()
sess.vncClient = nil
}
m.mu.Lock()
sess.Info.State = models.SessionDisconnected