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
+204
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"net"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
@@ -912,6 +913,16 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
return path, nil
}
// ---------------------------------------------------------------------------
// VNC
// ---------------------------------------------------------------------------
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
// The frontend connects noVNC to this URL for display.
func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
return s.sessions.GetVNCWebSocketURL(sessionID)
}
// ---------------------------------------------------------------------------
// Port forwarding
// ---------------------------------------------------------------------------
@@ -1457,3 +1468,196 @@ func derefProfiles(ptrs []*models.Profile) []models.Profile {
}
return result
}
// ---------------------------------------------------------------------------
// SSH Key Management
// ---------------------------------------------------------------------------
// SSHKeyInfo describes a key found in ~/.ssh/.
type SSHKeyInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Bits string `json:"bits"`
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKeyPath"`
}
// ListSSHKeyPairs returns detailed info about SSH key pairs in ~/.ssh/.
func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
if os.IsNotExist(err) {
return []SSHKeyInfo{}, nil
}
return nil, fmt.Errorf("unable to read .ssh directory: %w", err)
}
var keys []SSHKeyInfo
seen := make(map[string]bool)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Look for .pub files and match them with private keys
if !strings.HasSuffix(name, ".pub") {
continue
}
baseName := strings.TrimSuffix(name, ".pub")
privPath := filepath.Join(sshDir, baseName)
// Verify private key exists
if _, err := os.Stat(privPath); err != nil {
continue
}
if seen[baseName] {
continue
}
seen[baseName] = true
// Read public key content
pubBytes, err := os.ReadFile(filepath.Join(sshDir, name))
if err != nil {
continue
}
pubContent := strings.TrimSpace(string(pubBytes))
// Determine key type from public key content
keyType := "unknown"
parts := strings.Fields(pubContent)
if len(parts) >= 1 {
switch {
case strings.Contains(parts[0], "ed25519"):
keyType = "ed25519"
case strings.Contains(parts[0], "ecdsa"):
keyType = "ecdsa"
case strings.Contains(parts[0], "rsa"):
keyType = "rsa"
case strings.Contains(parts[0], "dsa"):
keyType = "dsa"
}
}
keys = append(keys, SSHKeyInfo{
Name: baseName,
Type: keyType,
PublicKey: pubContent,
PrivateKey: privPath,
})
}
if keys == nil {
keys = []SSHKeyInfo{}
}
return keys, nil
}
// GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
// Sanitise name — only allow alphanumeric, hyphens, underscores
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
// Ensure .ssh directory exists
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("unable to create .ssh directory: %w", err)
}
keyPath := filepath.Join(sshDir, name)
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("key %q already exists", name)
}
if keyType == "" {
keyType = "ed25519"
}
// Build ssh-keygen arguments
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
if passphrase != "" {
args = append(args, "-N", passphrase)
} else {
args = append(args, "-N", "")
}
if keyType == "rsa" && bits > 0 {
args = append(args, "-b", fmt.Sprintf("%d", bits))
}
// #nosec G204 — arguments are sanitised above
cmd := exec.Command("ssh-keygen", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
}
return nil
}
// GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine home directory: %w", err)
}
pubPath := filepath.Join(home, ".ssh", name+".pub")
data, err := os.ReadFile(pubPath)
if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
// DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, name)
pubPath := privPath + ".pub"
// Verify paths are within .ssh directory (prevent directory traversal)
absPriv, _ := filepath.Abs(privPath)
absSsh, _ := filepath.Abs(sshDir)
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
return fmt.Errorf("invalid key name")
}
var errs []string
if err := os.Remove(privPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "private key: "+err.Error())
}
if err := os.Remove(pubPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "public key: "+err.Error())
}
if len(errs) > 0 {
return fmt.Errorf("failed to delete: %s", strings.Join(errs, "; "))
}
return nil
}
+13
View File
@@ -12,6 +12,7 @@ const (
DefaultTelnetPort = 23
DefaultRAWPort = 23
DefaultGitHubPort = 443
DefaultVNCPort = 5900
)
// Default timeout values (in seconds).
@@ -108,6 +109,18 @@ func NewProfile(protocol Protocol) Profile {
SecretMode: SecretModeSaved,
RefreshIntervalSecs: 300,
}
case ProtocolVNC:
p.Port = DefaultVNCPort
p.VNC = &VNCConfig{
VNCPort: DefaultVNCPort,
SecretMode: SecretModeSaved,
ScaleToFit: true,
SharedSession: true,
Quality: 6,
SSHPort: DefaultSSHPort,
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
}
}
return p
+38
View File
@@ -12,6 +12,7 @@ const (
ProtocolTelnet Protocol = "telnet"
ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc"
)
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
@@ -80,6 +81,9 @@ type Profile struct {
// GitHub-specific configuration
GitHub *GitHubConfig `json:"github,omitempty"`
// VNC-specific configuration
VNC *VNCConfig `json:"vnc,omitempty"`
}
// SSHConfig holds SSH-specific settings for a connection profile.
@@ -192,6 +196,40 @@ type GitHubConfig struct {
RefreshIntervalSecs int `json:"refreshIntervalSecs"`
}
// VNCConfig holds VNC-specific settings for a connection profile.
type VNCConfig struct {
// VNCHost is the VNC server address (defaults to profile.Host if empty).
VNCHost string `json:"vncHost"`
// VNCPort is the VNC server port (5900 + display number, default 5900).
VNCPort int `json:"vncPort"`
// SecretMode controls how the VNC password is stored.
SecretMode SecretMode `json:"secretMode"`
// ViewOnly disables sending keyboard and mouse events to the server.
ViewOnly bool `json:"viewOnly"`
// ScaleToFit scales the remote framebuffer to fit the panel.
ScaleToFit bool `json:"scaleToFit"`
// Quality controls JPEG compression quality (1-9, 0 = default).
Quality int `json:"quality"`
// SharedSession allows other VNC viewers to remain connected.
SharedSession bool `json:"sharedSession"`
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
}
// GitHubRepo is a single repository returned to the frontend for display.
type GitHubRepo struct {
FullName string `json:"fullName"`
+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
@@ -0,0 +1,57 @@
-- Migration 004: Add VNC protocol support
-- Adds the 'vnc' value to the protocol CHECK constraint and a vnc_config column.
-- Step 1: Create the new table with the updated constraint.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc')),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Step 2: Copy existing data (vnc_config will be NULL for legacy rows).
INSERT OR IGNORE INTO profiles_new (
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
)
SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, NULL,
created_at, updated_at
FROM profiles;
-- Step 3: Drop old table and rename.
DROP TABLE profiles;
ALTER TABLE profiles_new RENAME TO profiles;
-- Step 4: Record the migration version.
INSERT INTO schema_version (version, applied_at) VALUES (4, datetime('now'));
+26 -16
View File
@@ -110,15 +110,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
_, err := s.db.Exec(`INSERT INTO profiles (
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.DisplayName, p.Description, string(p.Protocol),
p.Host, p.Port, p.Username,
string(tagsJSON), p.Folder, p.Environment, p.Notes,
@@ -126,7 +127,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
@@ -142,7 +143,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
FROM profiles WHERE id = ?`, id)
@@ -163,13 +164,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
res, err := s.db.Exec(`UPDATE profiles SET
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
updated_at=?
WHERE id=?`,
p.DisplayName, p.Description, string(p.Protocol),
@@ -179,7 +181,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
p.UpdatedAt.Format(time.RFC3339),
p.ID,
)
@@ -213,7 +215,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
FROM profiles ORDER BY display_name`)
if err != nil {
@@ -232,7 +234,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
FROM profiles
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
@@ -251,7 +253,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
if err != nil {
@@ -269,7 +271,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
if err != nil {
@@ -286,7 +288,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
if err != nil {
@@ -303,7 +305,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
if err != nil {
@@ -566,7 +568,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p := &models.Profile{}
var (
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
@@ -579,7 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
&favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&createdAt, &updatedAt,
)
if err != nil {
@@ -618,6 +620,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p.GitHub = &models.GitHubConfig{}
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
}
if vncJSON.Valid && vncJSON.String != "" {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
return p, nil
}
@@ -629,7 +635,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p := &models.Profile{}
var (
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
@@ -642,7 +648,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
&favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&createdAt, &updatedAt,
)
if err != nil {
@@ -681,6 +687,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p.GitHub = &models.GitHubConfig{}
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
}
if vncJSON.Valid && vncJSON.String != "" {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
result = append(result, p)
}
+2
View File
@@ -95,6 +95,8 @@ func ValidateProfile(p *models.Profile) error {
}
case models.ProtocolGitHub:
// GitHub profiles require no host-level validation beyond the common checks.
case models.ProtocolVNC:
// VNC profiles use vnc.vncHost or the profile-level host; validated at connect time.
default:
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
}
+286
View File
@@ -0,0 +1,286 @@
// Package vncclient provides a WebSocket-to-TCP proxy for VNC connections,
// with optional SSH tunnel support.
package vncclient
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// Client manages a VNC session, optionally tunnelled through SSH.
type Client struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
listener net.Listener
sshClient *ssh.Client
wsURL string
connected bool
onError func(error)
}
// Config holds the configuration for a VNC connection.
type Config struct {
// VNC server address
VNCHost string
VNCPort int
// SSH tunnel settings
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string // "password", "publickey"
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
// Callbacks
OnError func(error)
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 65536,
WriteBufferSize: 65536,
}
// NewClient creates a new VNC proxy client.
func NewClient(cfg Config) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
ctx: ctx,
cancel: cancel,
onError: cfg.OnError,
}
}
// Connect establishes the VNC connection (with optional SSH tunnel) and starts
// a local WebSocket proxy. Returns the WebSocket URL for the frontend.
func (c *Client) Connect(cfg Config) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return c.wsURL, nil
}
var vncConn net.Conn
var err error
vncAddr := fmt.Sprintf("%s:%d", cfg.VNCHost, cfg.VNCPort)
if cfg.SSHTunnel {
// Establish SSH connection first
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
sshConfig := &ssh.ClientConfig{
User: cfg.SSHUsername,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts
Timeout: 15 * time.Second,
}
// Configure auth methods
var authMethods []ssh.AuthMethod
if cfg.SSHAgentAuth && cfg.SSHAgentConn != nil {
agent := newSSHAgent(cfg.SSHAgentConn)
if agent != nil {
authMethods = append(authMethods, agent)
}
}
if len(cfg.SSHPrivateKey) > 0 {
var signer ssh.Signer
if cfg.SSHPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(cfg.SSHPrivateKey, []byte(cfg.SSHPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(cfg.SSHPrivateKey)
}
if err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
}
if cfg.SSHPassword != "" {
authMethods = append(authMethods, ssh.Password(cfg.SSHPassword))
}
sshConfig.Auth = authMethods
c.sshClient, err = ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return "", fmt.Errorf("SSH connection failed: %w", err)
}
// Dial VNC through the SSH tunnel
// When tunnelling, VNC address is relative to the SSH server
tunnelAddr := vncAddr
if cfg.VNCHost == "" || cfg.VNCHost == cfg.SSHHost {
tunnelAddr = fmt.Sprintf("127.0.0.1:%d", cfg.VNCPort)
}
vncConn, err = c.sshClient.Dial("tcp", tunnelAddr)
if err != nil {
c.sshClient.Close()
c.sshClient = nil
return "", fmt.Errorf("VNC tunnel dial failed: %w", err)
}
} else {
// Direct TCP connection to VNC server
vncConn, err = net.DialTimeout("tcp", vncAddr, 15*time.Second)
if err != nil {
return "", fmt.Errorf("VNC connection failed: %w", err)
}
}
// Start local WebSocket proxy
wsURL, err := c.startProxy(vncConn)
if err != nil {
vncConn.Close()
if c.sshClient != nil {
c.sshClient.Close()
c.sshClient = nil
}
return "", fmt.Errorf("failed to start WebSocket proxy: %w", err)
}
c.wsURL = wsURL
c.connected = true
return wsURL, nil
}
// startProxy creates a local HTTP server that upgrades to WebSocket and bridges
// traffic to the VNC TCP connection.
func (c *Client) startProxy(vncConn net.Conn) (string, error) {
// Listen on a random available port on localhost
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
c.listener = listener
port := listener.Addr().(*net.TCPAddr).Port
wsURL := fmt.Sprintf("ws://127.0.0.1:%d", port)
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
// Bridge WebSocket ↔ VNC TCP in both directions
done := make(chan struct{}, 2)
// VNC → WebSocket
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 65536)
for {
n, err := vncConn.Read(buf)
if n > 0 {
if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
return
}
}
if err != nil {
return
}
}
}()
// WebSocket → VNC
go func() {
defer func() { done <- struct{}{} }()
for {
_, msg, err := ws.ReadMessage()
if err != nil {
return
}
if _, err := vncConn.Write(msg); err != nil {
return
}
}
}()
// Wait for either direction to close
select {
case <-done:
case <-c.ctx.Done():
}
})
server := &http.Server{
Handler: mux,
ReadTimeout: 0, // VNC is long-lived
WriteTimeout: 0,
}
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
if c.onError != nil {
c.onError(fmt.Errorf("VNC proxy error: %w", err))
}
}
}()
// Close server when context is cancelled
go func() {
<-c.ctx.Done()
server.Close()
vncConn.Close()
}()
return wsURL, nil
}
// Close shuts down the VNC proxy and any SSH tunnel.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
c.cancel()
c.connected = false
if c.listener != nil {
c.listener.Close()
c.listener = nil
}
if c.sshClient != nil {
c.sshClient.Close()
c.sshClient = nil
}
}
// IsConnected returns whether the VNC session is active.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// WSURL returns the WebSocket URL for the frontend to connect to.
func (c *Client) WSURL() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.wsURL
}
// newSSHAgent creates an SSH agent auth method from the given connection.
func newSSHAgent(conn net.Conn) ssh.AuthMethod {
if conn == nil {
return nil
}
ag := agent.NewClient(conn)
signers, err := ag.Signers()
if err != nil || len(signers) == 0 {
return nil
}
return ssh.PublicKeys(signers...)
}