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
}