Files
nexus/internal/validation/validation.go
T
Andy Dixon 96fedb9348 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.
2026-05-21 12:52:57 +01:00

301 lines
9.5 KiB
Go

// Package validation provides thorough validation for connection profiles
// and their protocol-specific configurations.
package validation
import (
"fmt"
"net"
"strings"
"github.com/andydixon/nexus/internal/models"
)
// ValidationError describes a single field-level validation failure.
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// Error satisfies the error interface.
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationErrors collects multiple validation failures into one error.
type ValidationErrors struct {
Errors []ValidationError `json:"errors"`
}
// Error satisfies the error interface, returning a summary of all failures.
func (e *ValidationErrors) Error() string {
msgs := make([]string, len(e.Errors))
for i, err := range e.Errors {
msgs[i] = err.Error()
}
return fmt.Sprintf("validation failed: %s", strings.Join(msgs, "; "))
}
// HasErrors reports whether any validation errors have been collected.
func (e *ValidationErrors) HasErrors() bool {
return len(e.Errors) > 0
}
// Add appends a validation error to the collection.
func (e *ValidationErrors) Add(field, message string) {
e.Errors = append(e.Errors, ValidationError{Field: field, Message: message})
}
// ValidateProfile validates all fields of a connection profile, including
// protocol-specific configuration blocks.
func ValidateProfile(p *models.Profile) error {
errs := &ValidationErrors{}
if strings.TrimSpace(p.ID) == "" {
errs.Add("id", "profile ID must not be empty")
}
if strings.TrimSpace(p.DisplayName) == "" {
errs.Add("displayName", "display name must not be empty")
}
if err := ValidateHost(p.Host); err != nil {
errs.Add("host", err.(*ValidationError).Message)
}
if err := ValidatePort(p.Port); err != nil {
errs.Add("port", err.(*ValidationError).Message)
}
switch p.Protocol {
case models.ProtocolSSH:
if strings.TrimSpace(p.Username) == "" {
errs.Add("username", "username is required for SSH connections")
} else if err := ValidateUsername(p.Username); err != nil {
errs.Add("username", err.(*ValidationError).Message)
}
if p.SSH == nil {
errs.Add("ssh", "SSH configuration is required for SSH protocol")
} else if err := ValidateSSHConfig(p.SSH); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
case models.ProtocolTelnet:
if p.Telnet == nil {
errs.Add("telnet", "Telnet configuration is required for Telnet protocol")
} else if err := ValidateTelnetConfig(p.Telnet); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
case models.ProtocolRAW:
if p.RAW == nil {
errs.Add("raw", "RAW configuration is required for RAW protocol")
} else if err := ValidateRAWConfig(p.RAW); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
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))
}
if p.ConnectTimeout < 0 {
errs.Add("connectTimeout", "connect timeout must not be negative")
}
if p.ReadTimeout < 0 {
errs.Add("readTimeout", "read timeout must not be negative")
}
if p.TerminalFontSize < 1 || p.TerminalFontSize > 72 {
errs.Add("terminalFontSize", "font size must be between 1 and 72")
}
if p.TerminalScrollback < 0 {
errs.Add("terminalScrollback", "scrollback must not be negative")
}
if errs.HasErrors() {
return errs
}
return nil
}
// ValidateHost ensures the host string is non-empty and syntactically valid.
func ValidateHost(host string) error {
h := strings.TrimSpace(host)
if h == "" {
return &ValidationError{Field: "host", Message: "host must not be empty"}
}
// Reject hosts that contain whitespace.
if strings.ContainsAny(h, " \t\n\r") {
return &ValidationError{Field: "host", Message: "host must not contain whitespace"}
}
// Reject hosts that contain a scheme (e.g. "ssh://").
if strings.Contains(h, "://") {
return &ValidationError{Field: "host", Message: "host must not include a URI scheme"}
}
// If it looks like an IP address, parse it properly.
if ip := net.ParseIP(h); ip == nil {
// Not a plain IP — verify it is a plausible hostname.
if strings.HasPrefix(h, "-") {
return &ValidationError{Field: "host", Message: "host must not begin with a hyphen"}
}
}
return nil
}
// ValidatePort checks that the port number is within the valid TCP range.
func ValidatePort(port int) error {
if port < 1 || port > 65535 {
return &ValidationError{Field: "port", Message: "port must be between 1 and 65535"}
}
return nil
}
// ValidateUsername checks that the username is non-empty and free of control characters.
func ValidateUsername(username string) error {
u := strings.TrimSpace(username)
if u == "" {
return &ValidationError{Field: "username", Message: "username must not be empty"}
}
for _, r := range u {
if r < 0x20 || r == 0x7f {
return &ValidationError{Field: "username", Message: "username must not contain control characters"}
}
}
return nil
}
// ValidateSSHConfig validates the SSH-specific configuration block.
func ValidateSSHConfig(cfg *models.SSHConfig) error {
errs := &ValidationErrors{}
if len(cfg.AuthMethods) == 0 {
errs.Add("ssh.authMethods", "at least one authentication method is required")
}
validAuth := map[string]bool{
"password": true, "publickey": true, "keyboard-interactive": true,
}
for _, m := range cfg.AuthMethods {
if !validAuth[m] {
errs.Add("ssh.authMethods", fmt.Sprintf("unsupported authentication method %q", m))
}
}
validSecret := map[models.SecretMode]bool{
models.SecretModeSaved: true, models.SecretModeSessionOnly: true, models.SecretModeAskEveryTime: true,
}
if !validSecret[cfg.SecretMode] {
errs.Add("ssh.secretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SecretMode))
}
if !validSecret[cfg.KeySecretMode] {
errs.Add("ssh.keySecretMode", fmt.Sprintf("unsupported key secret mode %q", cfg.KeySecretMode))
}
validPolicy := map[models.HostKeyPolicy]bool{
models.HostKeyStrict: true, models.HostKeyAsk: true,
models.HostKeyAcceptNew: true, models.HostKeyInsecure: true,
}
if !validPolicy[cfg.HostKeyPolicy] {
errs.Add("ssh.hostKeyPolicy", fmt.Sprintf("unsupported host key policy %q", cfg.HostKeyPolicy))
}
if cfg.KeepaliveInterval < 0 {
errs.Add("ssh.keepaliveInterval", "keepalive interval must not be negative")
}
if cfg.KeepaliveCountMax < 0 {
errs.Add("ssh.keepaliveCountMax", "keepalive count max must not be negative")
}
// Validate each jump host entry.
for i, jh := range cfg.JumpHosts {
prefix := fmt.Sprintf("ssh.jumpHosts[%d]", i)
if strings.TrimSpace(jh.Host) == "" {
errs.Add(prefix+".host", "jump host address must not be empty")
}
if jh.Port < 1 || jh.Port > 65535 {
errs.Add(prefix+".port", "jump host port must be between 1 and 65535")
}
if strings.TrimSpace(jh.Username) == "" {
errs.Add(prefix+".username", "jump host username must not be empty")
}
}
// Validate each port-forwarding rule.
for i, pf := range cfg.PortForwards {
prefix := fmt.Sprintf("ssh.portForwards[%d]", i)
if pf.Direction != models.PortForwardLocal && pf.Direction != models.PortForwardRemote {
errs.Add(prefix+".direction", fmt.Sprintf("unsupported forwarding direction %q", pf.Direction))
}
if pf.BindPort < 1 || pf.BindPort > 65535 {
errs.Add(prefix+".bindPort", "bind port must be between 1 and 65535")
}
if pf.DestPort < 1 || pf.DestPort > 65535 {
errs.Add(prefix+".destPort", "destination port must be between 1 and 65535")
}
if strings.TrimSpace(pf.DestHost) == "" {
errs.Add(prefix+".destHost", "destination host must not be empty")
}
}
if errs.HasErrors() {
return errs
}
return nil
}
// ValidateTelnetConfig validates the Telnet-specific configuration block.
func ValidateTelnetConfig(cfg *models.TelnetConfig) error {
errs := &ValidationErrors{}
validEnding := map[models.LineEnding]bool{
models.LineEndingLF: true, models.LineEndingCR: true,
models.LineEndingCRLF: true, models.LineEndingNone: true,
}
if !validEnding[cfg.LineEnding] {
errs.Add("telnet.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding))
}
if strings.TrimSpace(cfg.Encoding) == "" {
errs.Add("telnet.encoding", "encoding must not be empty")
}
if errs.HasErrors() {
return errs
}
return nil
}
// ValidateRAWConfig validates the RAW TCP-specific configuration block.
func ValidateRAWConfig(cfg *models.RAWConfig) error {
errs := &ValidationErrors{}
validEnding := map[models.LineEnding]bool{
models.LineEndingLF: true, models.LineEndingCR: true,
models.LineEndingCRLF: true, models.LineEndingNone: true,
}
if !validEnding[cfg.LineEnding] {
errs.Add("raw.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding))
}
if strings.TrimSpace(cfg.Encoding) == "" {
errs.Add("raw.encoding", "encoding must not be empty")
}
validFormat := map[string]bool{"text": true, "hex": true, "escaped": true}
for i, preset := range cfg.PayloadPresets {
prefix := fmt.Sprintf("raw.payloadPresets[%d]", i)
if strings.TrimSpace(preset.Name) == "" {
errs.Add(prefix+".name", "payload preset name must not be empty")
}
if !validFormat[preset.Format] {
errs.Add(prefix+".format", fmt.Sprintf("unsupported payload format %q", preset.Format))
}
}
if errs.HasErrors() {
return errs
}
return nil
}