Renames the product across the codebase: Go module path, config/data paths, keychain service name, export file extension, build tooling, docs, and UI text. Sidebar brand mark now reads ">_ AlchementaryLink" with "Alchementary" in the connection-group gold accent colour, and the window title is "Alchementary Link". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T8bJS8BCtY5GeSG2HBj1n
519 lines
16 KiB
Go
519 lines
16 KiB
Go
// Package validation provides thorough validation for connection profiles
|
|
// and their protocol-specific configurations.
|
|
package validation
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/andydixon/link/internal/models"
|
|
"github.com/lrstanley/girc"
|
|
)
|
|
|
|
// 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 p == nil {
|
|
return &ValidationError{Field: "profile", Message: "profile must not be nil"}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
switch p.Protocol {
|
|
case models.ProtocolSSH:
|
|
validateProfileHostPort(errs, p.Host, p.Port)
|
|
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:
|
|
validateProfileHostPort(errs, p.Host, p.Port)
|
|
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:
|
|
validateProfileHostPort(errs, p.Host, p.Port)
|
|
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:
|
|
if p.GitHub == nil {
|
|
errs.Add("github", "GitHub configuration is required for GitHub protocol")
|
|
} else if err := ValidateGitHubConfig(p.GitHub); err != nil {
|
|
if ve, ok := err.(*ValidationErrors); ok {
|
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
|
}
|
|
}
|
|
case models.ProtocolVNC:
|
|
if p.VNC == nil {
|
|
errs.Add("vnc", "VNC configuration is required for VNC protocol")
|
|
} else if err := ValidateVNCConfig(p.Host, p.VNC); err != nil {
|
|
if ve, ok := err.(*ValidationErrors); ok {
|
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
|
}
|
|
}
|
|
case models.ProtocolIRC:
|
|
validateProfileHostPort(errs, p.Host, p.Port)
|
|
if p.IRC == nil {
|
|
errs.Add("irc", "IRC configuration is required for IRC protocol")
|
|
} else if err := ValidateIRCConfig(p.IRC); err != nil {
|
|
if ve, ok := err.(*ValidationErrors); ok {
|
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
func validateProfileHostPort(errs *ValidationErrors, host string, port int) {
|
|
if err := ValidateHost(host); err != nil {
|
|
errs.Add("host", err.(*ValidationError).Message)
|
|
}
|
|
if err := ValidatePort(port); err != nil {
|
|
errs.Add("port", err.(*ValidationError).Message)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ValidateGitHubConfig validates GitHub API settings before tokens are sent.
|
|
func ValidateGitHubConfig(cfg *models.GitHubConfig) error {
|
|
errs := &ValidationErrors{}
|
|
|
|
if cfg == nil {
|
|
return &ValidationError{Field: "github", Message: "GitHub configuration must not be nil"}
|
|
}
|
|
|
|
u, err := url.Parse(strings.TrimSpace(cfg.BaseURL))
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
errs.Add("github.baseURL", "API base URL must be an absolute HTTPS URL")
|
|
} else {
|
|
if u.User != nil {
|
|
errs.Add("github.baseURL", "API base URL must not include credentials")
|
|
}
|
|
if u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname())) {
|
|
errs.Add("github.baseURL", "API base URL must use HTTPS unless it points to loopback")
|
|
}
|
|
}
|
|
|
|
validSecret := map[models.SecretMode]bool{
|
|
models.SecretModeSaved: true, models.SecretModeSessionOnly: true, models.SecretModeAskEveryTime: true,
|
|
}
|
|
if !validSecret[cfg.SecretMode] {
|
|
errs.Add("github.secretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SecretMode))
|
|
}
|
|
if cfg.RefreshIntervalSecs != 0 && cfg.RefreshIntervalSecs < 60 {
|
|
errs.Add("github.refreshIntervalSecs", "refresh interval must be at least 60 seconds")
|
|
}
|
|
|
|
if errs.HasErrors() {
|
|
return errs
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateVNCConfig validates VNC connection settings.
|
|
func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error {
|
|
errs := &ValidationErrors{}
|
|
|
|
if cfg == nil {
|
|
return &ValidationError{Field: "vnc", Message: "VNC configuration must not be nil"}
|
|
}
|
|
|
|
vncHost := strings.TrimSpace(cfg.VNCHost)
|
|
if vncHost == "" {
|
|
vncHost = strings.TrimSpace(profileHost)
|
|
}
|
|
if err := ValidateHost(vncHost); err != nil {
|
|
errs.Add("vnc.vncHost", err.(*ValidationError).Message)
|
|
}
|
|
if err := ValidatePort(cfg.VNCPort); err != nil {
|
|
errs.Add("vnc.vncPort", err.(*ValidationError).Message)
|
|
}
|
|
if cfg.Quality < 0 || cfg.Quality > 9 {
|
|
errs.Add("vnc.quality", "quality must be between 0 and 9")
|
|
}
|
|
|
|
if cfg.SSHTunnel {
|
|
if err := ValidateHost(cfg.SSHHost); err != nil {
|
|
errs.Add("vnc.sshHost", err.(*ValidationError).Message)
|
|
}
|
|
if err := ValidatePort(cfg.SSHPort); err != nil {
|
|
errs.Add("vnc.sshPort", err.(*ValidationError).Message)
|
|
}
|
|
if strings.TrimSpace(cfg.SSHUsername) == "" {
|
|
errs.Add("vnc.sshUsername", "SSH username is required when tunnelling")
|
|
} else if err := ValidateUsername(cfg.SSHUsername); err != nil {
|
|
errs.Add("vnc.sshUsername", err.(*ValidationError).Message)
|
|
}
|
|
if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" {
|
|
errs.Add("vnc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod))
|
|
}
|
|
}
|
|
|
|
if errs.HasErrors() {
|
|
return errs
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateIRCConfig validates IRC connection settings.
|
|
func ValidateIRCConfig(cfg *models.IRCConfig) error {
|
|
errs := &ValidationErrors{}
|
|
|
|
if cfg == nil {
|
|
return &ValidationError{Field: "irc", Message: "IRC configuration must not be nil"}
|
|
}
|
|
|
|
nick := strings.TrimSpace(cfg.Nick)
|
|
if nick == "" {
|
|
errs.Add("irc.nick", "nickname is required")
|
|
} else if !girc.IsValidNick(nick) {
|
|
errs.Add("irc.nick", "nickname is not valid for IRC")
|
|
}
|
|
|
|
if strings.ContainsAny(cfg.RealName, "\r\n\x00") {
|
|
errs.Add("irc.realName", "real name must not contain control characters")
|
|
}
|
|
|
|
if !isValidSecretMode(cfg.ServerPasswordMode) {
|
|
errs.Add("irc.serverPasswordMode", fmt.Sprintf("unsupported secret mode %q", cfg.ServerPasswordMode))
|
|
}
|
|
if !isValidSecretMode(cfg.NickServMode) {
|
|
errs.Add("irc.nickServMode", fmt.Sprintf("unsupported secret mode %q", cfg.NickServMode))
|
|
}
|
|
if cfg.NickServIdentify && !isValidNickServAccount(cfg.NickServAccount) {
|
|
errs.Add("irc.nickServAccount", "NickServ account must not contain control characters")
|
|
}
|
|
|
|
seenRooms := map[string]bool{}
|
|
for i, room := range cfg.AutoJoinRooms {
|
|
field := fmt.Sprintf("irc.autoJoinRooms[%d]", i)
|
|
name := strings.TrimSpace(room)
|
|
if name == "" {
|
|
errs.Add(field, "room name must not be empty")
|
|
continue
|
|
}
|
|
if !girc.IsValidChannel(name) {
|
|
errs.Add(field, "room name must start with a valid IRC channel prefix")
|
|
}
|
|
key := strings.ToLower(name)
|
|
if seenRooms[key] {
|
|
errs.Add(field, "room is listed more than once")
|
|
}
|
|
seenRooms[key] = true
|
|
}
|
|
|
|
if cfg.SSHTunnel {
|
|
if err := ValidateHost(cfg.SSHHost); err != nil {
|
|
errs.Add("irc.sshHost", err.(*ValidationError).Message)
|
|
}
|
|
if err := ValidatePort(cfg.SSHPort); err != nil {
|
|
errs.Add("irc.sshPort", err.(*ValidationError).Message)
|
|
}
|
|
if strings.TrimSpace(cfg.SSHUsername) == "" {
|
|
errs.Add("irc.sshUsername", "SSH username is required when tunnelling")
|
|
} else if err := ValidateUsername(cfg.SSHUsername); err != nil {
|
|
errs.Add("irc.sshUsername", err.(*ValidationError).Message)
|
|
}
|
|
if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" {
|
|
errs.Add("irc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod))
|
|
}
|
|
if !isValidSecretMode(cfg.SSHSecretMode) {
|
|
errs.Add("irc.sshSecretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SSHSecretMode))
|
|
}
|
|
}
|
|
|
|
if cfg.MaxReconnectTries < 0 {
|
|
errs.Add("irc.maxReconnectTries", "max reconnect attempts must not be negative")
|
|
}
|
|
|
|
if errs.HasErrors() {
|
|
return errs
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isValidSecretMode(mode models.SecretMode) bool {
|
|
switch mode {
|
|
case models.SecretModeSaved, models.SecretModeSessionOnly, models.SecretModeAskEveryTime:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isValidNickServAccount(account string) bool {
|
|
for _, r := range account {
|
|
if r < 0x20 || r == 0x7f {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
h := strings.ToLower(strings.TrimSpace(host))
|
|
if h == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(h)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|