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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
Reference in New Issue
Block a user