mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
714 lines
19 KiB
Go
714 lines
19 KiB
Go
// Copyright 2025.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// fieldValidator contains validation rules for SSH configuration fields
|
|
type fieldValidator struct {
|
|
Required bool
|
|
Pattern *regexp.Regexp
|
|
Validate func(string) error
|
|
Message string
|
|
}
|
|
|
|
// ValidationState tracks validation errors for each field
|
|
type ValidationState struct {
|
|
errors map[string]string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewValidationState creates a new validation state
|
|
func NewValidationState() *ValidationState {
|
|
return &ValidationState{
|
|
errors: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// SetError sets or clears an error for a field
|
|
func (v *ValidationState) SetError(field, errMsg string) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
if errMsg == "" {
|
|
delete(v.errors, field)
|
|
} else {
|
|
v.errors[field] = errMsg
|
|
}
|
|
}
|
|
|
|
// GetError gets the error for a specific field
|
|
func (v *ValidationState) GetError(field string) string {
|
|
v.mu.RLock()
|
|
defer v.mu.RUnlock()
|
|
return v.errors[field]
|
|
}
|
|
|
|
// HasErrors checks if there are any validation errors
|
|
func (v *ValidationState) HasErrors() bool {
|
|
v.mu.RLock()
|
|
defer v.mu.RUnlock()
|
|
return len(v.errors) > 0
|
|
}
|
|
|
|
// GetErrorCount returns the number of validation errors
|
|
func (v *ValidationState) GetErrorCount() int {
|
|
v.mu.RLock()
|
|
defer v.mu.RUnlock()
|
|
return len(v.errors)
|
|
}
|
|
|
|
// GetAllErrors returns all validation errors in field order
|
|
func (v *ValidationState) GetAllErrors() []string {
|
|
v.mu.RLock()
|
|
defer v.mu.RUnlock()
|
|
|
|
// Define field order for consistent error display
|
|
fieldOrder := []string{
|
|
"Alias", "Host", "Port", "User", "Keys", "Tags",
|
|
"ConnectTimeout", "ConnectionAttempts", "ServerAliveInterval", "ServerAliveCountMax",
|
|
"IPQoS", "BindAddress", "LocalForward", "RemoteForward", "DynamicForward",
|
|
"NumberOfPasswordPrompts", "CanonicalizeMaxDots", "EscapeChar",
|
|
}
|
|
|
|
// Create a set for O(1) lookups
|
|
fieldOrderSet := make(map[string]bool, len(fieldOrder))
|
|
for _, field := range fieldOrder {
|
|
fieldOrderSet[field] = true
|
|
}
|
|
|
|
errors := make([]string, 0, len(v.errors))
|
|
|
|
// Add errors in defined order
|
|
for _, field := range fieldOrder {
|
|
if err, exists := v.errors[field]; exists {
|
|
errors = append(errors, fmt.Sprintf("%s: %s", field, err))
|
|
}
|
|
}
|
|
|
|
// Add any other errors not in the defined order
|
|
for field, err := range v.errors {
|
|
if !fieldOrderSet[field] {
|
|
errors = append(errors, fmt.Sprintf("%s: %s", field, err))
|
|
}
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
// Clear removes all validation errors
|
|
func (v *ValidationState) Clear() {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.errors = make(map[string]string)
|
|
}
|
|
|
|
// invalidHostChars contains characters that are not allowed in hostnames
|
|
const invalidHostChars = "@#$%^&*()=+[]{}|\\;:'\"<>,?/"
|
|
|
|
// invalidAddressChars contains characters that are not allowed in bind addresses
|
|
const invalidAddressChars = "@#$%^&()=+{}|\\;:'\"<>,?/"
|
|
|
|
// GetFieldValidators returns validation rules for SSH configuration fields
|
|
func GetFieldValidators() map[string]fieldValidator {
|
|
validators := make(map[string]fieldValidator)
|
|
|
|
// Basic fields
|
|
validators["Alias"] = fieldValidator{
|
|
Required: true,
|
|
Pattern: regexp.MustCompile(`^[a-zA-Z0-9._-]+$`),
|
|
Message: "Alias is required and can only contain letters, numbers, dots, hyphens, and underscores",
|
|
}
|
|
validators["Host"] = fieldValidator{
|
|
Required: true,
|
|
Validate: validateHost,
|
|
Message: "Host is required and must be a valid hostname or IP address",
|
|
}
|
|
validators["Port"] = fieldValidator{
|
|
Pattern: regexp.MustCompile(`^([1-9]\d{0,4})$`),
|
|
Validate: validatePort,
|
|
Message: "Port must be between 1 and 65535",
|
|
}
|
|
validators["User"] = fieldValidator{
|
|
Pattern: regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._-]*$`),
|
|
Message: "User must start with a letter and contain only letters, numbers, dots, hyphens, and underscores",
|
|
}
|
|
validators["Keys"] = fieldValidator{
|
|
Validate: validateKeyPaths,
|
|
Message: "Key file not found or not accessible",
|
|
}
|
|
|
|
// Connection fields
|
|
validators["ConnectTimeout"] = fieldValidator{
|
|
Validate: validateConnectTimeout,
|
|
Message: "ConnectTimeout must be a positive number or 'none'",
|
|
}
|
|
validators["ConnectionAttempts"] = fieldValidator{
|
|
Pattern: regexp.MustCompile(`^[1-9]\d*$`),
|
|
Message: "ConnectionAttempts must be a positive number",
|
|
}
|
|
validators["ServerAliveInterval"] = fieldValidator{
|
|
Pattern: regexp.MustCompile(`^\d+$`),
|
|
Validate: validateNonNegativeNumber,
|
|
Message: "ServerAliveInterval must be a non-negative number",
|
|
}
|
|
validators["ServerAliveCountMax"] = fieldValidator{
|
|
Pattern: regexp.MustCompile(`^\d+$`),
|
|
Validate: validateNonNegativeNumber,
|
|
Message: "ServerAliveCountMax must be a non-negative number",
|
|
}
|
|
validators["IPQoS"] = fieldValidator{
|
|
Validate: validateIPQoS,
|
|
Message: "IPQoS must be valid QoS values (e.g., 'af21 cs1', 'lowdelay', 'ef')",
|
|
}
|
|
|
|
// Address and forwarding fields
|
|
validators["BindAddress"] = fieldValidator{
|
|
Validate: validateBindAddress,
|
|
Message: "BindAddress must be a valid IP address, hostname, or '*'",
|
|
}
|
|
validators["LocalForward"] = fieldValidator{
|
|
Validate: validatePortForward,
|
|
Message: "LocalForward must be in format '[bind_address:]port:host:hostport'",
|
|
}
|
|
validators["RemoteForward"] = fieldValidator{
|
|
Validate: validatePortForward,
|
|
Message: "RemoteForward must be in format '[bind_address:]port:host:hostport'",
|
|
}
|
|
validators["DynamicForward"] = fieldValidator{
|
|
Validate: validateDynamicForward,
|
|
Message: "DynamicForward must be in format '[bind_address:]port'",
|
|
}
|
|
|
|
// Authentication fields
|
|
validators["NumberOfPasswordPrompts"] = fieldValidator{
|
|
Pattern: regexp.MustCompile(`^\d+$`),
|
|
Validate: validatePasswordPrompts,
|
|
Message: "NumberOfPasswordPrompts must be between 0 and 10",
|
|
}
|
|
|
|
// Advanced fields
|
|
validators["CanonicalizeMaxDots"] = fieldValidator{
|
|
Pattern: regexp.MustCompile(`^\d+$`),
|
|
Validate: validateNonNegativeNumber,
|
|
Message: "CanonicalizeMaxDots must be a non-negative number",
|
|
}
|
|
validators["EscapeChar"] = fieldValidator{
|
|
Validate: validateEscapeChar,
|
|
Message: "EscapeChar must be a single character, 'none', or ^X format (e.g., ^A)",
|
|
}
|
|
|
|
// Security fields
|
|
validators["UserKnownHostsFile"] = fieldValidator{
|
|
Validate: validateKnownHostsFiles,
|
|
Message: "Known hosts file not found or not accessible",
|
|
}
|
|
|
|
return validators
|
|
}
|
|
|
|
// validatePort validates port number
|
|
func validatePort(value string) error {
|
|
if value == "" {
|
|
return nil // Port is optional
|
|
}
|
|
port, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid port number")
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
return fmt.Errorf("port must be between 1 and 65535")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateConnectTimeout validates connection timeout
|
|
func validateConnectTimeout(value string) error {
|
|
if value == "" || value == "none" {
|
|
return nil
|
|
}
|
|
timeout, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid timeout value")
|
|
}
|
|
if timeout <= 0 {
|
|
return fmt.Errorf("timeout must be positive or 'none'")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateNonNegativeNumber validates that a value is a non-negative number
|
|
func validateNonNegativeNumber(value string) error {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
num, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid number")
|
|
}
|
|
if num < 0 {
|
|
return fmt.Errorf("must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validatePasswordPrompts validates NumberOfPasswordPrompts
|
|
func validatePasswordPrompts(value string) error {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
num, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid number")
|
|
}
|
|
if num < 0 || num > 10 {
|
|
return fmt.Errorf("must be between 0 and 10")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateEscapeChar validates escape character format
|
|
func validateEscapeChar(value string) error {
|
|
if value == "" || value == "none" || value == "~" {
|
|
return nil
|
|
}
|
|
// Support ^X format (Ctrl+X)
|
|
if len(value) == 2 && value[0] == '^' {
|
|
char := value[1]
|
|
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') {
|
|
return nil
|
|
}
|
|
}
|
|
// Single printable character
|
|
if len(value) == 1 && value[0] >= 32 && value[0] <= 126 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("invalid escape character format")
|
|
}
|
|
|
|
// validateIPQoS validates IPQoS values
|
|
func validateIPQoS(value string) error {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
validValues := map[string]bool{
|
|
"af11": true, "af12": true, "af13": true,
|
|
"af21": true, "af22": true, "af23": true,
|
|
"af31": true, "af32": true, "af33": true,
|
|
"af41": true, "af42": true, "af43": true,
|
|
"cs0": true, "cs1": true, "cs2": true, "cs3": true,
|
|
"cs4": true, "cs5": true, "cs6": true, "cs7": true,
|
|
"ef": true, "le": true,
|
|
"lowdelay": true, "throughput": true, "reliability": true, "none": true,
|
|
}
|
|
// Can be single value or two space-separated values
|
|
parts := strings.Fields(value)
|
|
if len(parts) > 2 {
|
|
return fmt.Errorf("IPQoS accepts at most 2 values")
|
|
}
|
|
for _, part := range parts {
|
|
if !validValues[strings.ToLower(part)] {
|
|
return fmt.Errorf("invalid IPQoS value: %s", part)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateFilePath validates a single file path for existence and readability
|
|
func validateFilePath(path string) (exists bool, accessible bool, isDir bool) {
|
|
// Get home directory for tilde expansion
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
homeDir = ""
|
|
}
|
|
|
|
// Expand tilde notation
|
|
expandedPath := path
|
|
if strings.HasPrefix(path, "~/") && homeDir != "" {
|
|
expandedPath = filepath.Join(homeDir, path[2:])
|
|
} else if strings.HasPrefix(path, "~") && homeDir != "" {
|
|
// Handle ~ alone
|
|
expandedPath = homeDir
|
|
}
|
|
|
|
// Check if file exists
|
|
info, err := os.Stat(expandedPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, false, false // File doesn't exist
|
|
}
|
|
// Permission denied or other error
|
|
return true, false, false // File exists but not accessible
|
|
}
|
|
|
|
// Check if it's a directory
|
|
if info.IsDir() {
|
|
return true, true, true
|
|
}
|
|
|
|
// Check if file is readable
|
|
// #nosec G304 - expandedPath is validated user input
|
|
file, err := os.Open(expandedPath)
|
|
if err != nil {
|
|
return true, false, false // File exists but not readable
|
|
}
|
|
_ = file.Close()
|
|
|
|
return true, true, false // File exists and is readable
|
|
}
|
|
|
|
// buildFileValidationError builds an error message from invalid and inaccessible file paths
|
|
func buildFileValidationError(invalidPaths, inaccessiblePaths []string) error {
|
|
var errors []string
|
|
if len(invalidPaths) > 0 {
|
|
errors = append(errors, fmt.Sprintf("file(s) not found: %s", strings.Join(invalidPaths, ", ")))
|
|
}
|
|
if len(inaccessiblePaths) > 0 {
|
|
errors = append(errors, fmt.Sprintf("file(s) not accessible: %s", strings.Join(inaccessiblePaths, ", ")))
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return fmt.Errorf("%s", strings.Join(errors, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateFilePaths validates multiple file paths with a custom separator
|
|
func validateFilePaths(files string, separator string) error {
|
|
if files == "" {
|
|
return nil
|
|
}
|
|
// Check for invalid characters first, before trimming
|
|
if strings.ContainsAny(files, "\n\r\t") {
|
|
return fmt.Errorf("file path contains invalid characters")
|
|
}
|
|
|
|
var paths []string
|
|
if separator == " " {
|
|
// For space separator, use Fields to handle multiple spaces
|
|
paths = strings.Fields(files)
|
|
} else {
|
|
// For other separators like comma
|
|
paths = strings.Split(files, separator)
|
|
}
|
|
|
|
var invalidPaths []string
|
|
var inaccessiblePaths []string
|
|
|
|
for _, path := range paths {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
|
|
exists, accessible, isDir := validateFilePath(path)
|
|
|
|
switch {
|
|
case !exists:
|
|
invalidPaths = append(invalidPaths, path)
|
|
case isDir:
|
|
invalidPaths = append(invalidPaths, fmt.Sprintf("%s (is a directory)", path))
|
|
case !accessible:
|
|
inaccessiblePaths = append(inaccessiblePaths, path)
|
|
}
|
|
}
|
|
|
|
return buildFileValidationError(invalidPaths, inaccessiblePaths)
|
|
}
|
|
|
|
// validateKeyPaths validates SSH key file paths (comma-separated)
|
|
func validateKeyPaths(keys string) error {
|
|
return validateFilePaths(keys, ",")
|
|
}
|
|
|
|
// validateKnownHostsFiles validates known_hosts file paths (space-separated)
|
|
func validateKnownHostsFiles(files string) error {
|
|
// Empty is valid - SSH will use default
|
|
return validateFilePaths(files, " ")
|
|
}
|
|
|
|
// validateHost validates a hostname or IP address
|
|
func validateHost(host string) error {
|
|
if host == "" {
|
|
return fmt.Errorf("host is required")
|
|
}
|
|
|
|
// Check for spaces
|
|
if strings.Contains(host, " ") {
|
|
return fmt.Errorf("host cannot contain spaces")
|
|
}
|
|
|
|
// Try to parse as IP address first
|
|
if net.ParseIP(host) != nil {
|
|
return nil
|
|
}
|
|
|
|
// Validate as hostname
|
|
return validateHostname(host)
|
|
}
|
|
|
|
// validateHostname validates a hostname (not IP)
|
|
func validateHostname(host string) error {
|
|
if len(host) > 253 {
|
|
return fmt.Errorf("hostname too long")
|
|
}
|
|
|
|
// Check for invalid characters using a single check
|
|
if strings.ContainsAny(host, invalidHostChars) {
|
|
return fmt.Errorf("host contains invalid characters")
|
|
}
|
|
|
|
// Check hostname format
|
|
if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
|
|
return fmt.Errorf("hostname cannot start or end with a dot")
|
|
}
|
|
|
|
if strings.Contains(host, "..") {
|
|
return fmt.Errorf("hostname cannot contain consecutive dots")
|
|
}
|
|
|
|
// Validate each label
|
|
return validateHostLabels(host)
|
|
}
|
|
|
|
// validateHostLabels validates each label in a hostname
|
|
func validateHostLabels(host string) error {
|
|
labels := strings.Split(host, ".")
|
|
for _, label := range labels {
|
|
if err := validateHostLabel(label); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateHostLabel validates a single hostname label
|
|
func validateHostLabel(label string) error {
|
|
if label == "" {
|
|
return fmt.Errorf("hostname has empty label")
|
|
}
|
|
if len(label) > 63 {
|
|
return fmt.Errorf("hostname label too long")
|
|
}
|
|
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
|
return fmt.Errorf("hostname label cannot start or end with hyphen")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validatePortForward validates port forwarding specification
|
|
func validatePortForward(forward string) error {
|
|
if forward == "" {
|
|
return nil // Port forwarding is optional
|
|
}
|
|
|
|
// Support multiple forwards separated by comma
|
|
forwards := strings.Split(forward, ",")
|
|
for _, fwd := range forwards {
|
|
fwd = strings.TrimSpace(fwd)
|
|
if fwd == "" {
|
|
continue
|
|
}
|
|
|
|
// Format: [bind_address:]port:host:hostport
|
|
parts := strings.Split(fwd, ":")
|
|
if len(parts) < 3 || len(parts) > 4 {
|
|
return fmt.Errorf("invalid format, expected [bind_address:]port:host:hostport")
|
|
}
|
|
|
|
// Validate ports
|
|
var portIdx, hostPortIdx int
|
|
if len(parts) == 3 {
|
|
// port:host:hostport
|
|
portIdx = 0
|
|
hostPortIdx = 2
|
|
} else {
|
|
// bind_address:port:host:hostport
|
|
portIdx = 1
|
|
hostPortIdx = 3
|
|
|
|
// Validate bind address
|
|
if parts[0] != "" && parts[0] != "*" {
|
|
if err := validateBindAddress(parts[0]); err != nil {
|
|
return fmt.Errorf("invalid bind address: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate port numbers
|
|
port, err := strconv.Atoi(parts[portIdx])
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return fmt.Errorf("invalid port number: %s", parts[portIdx])
|
|
}
|
|
|
|
hostPort, err := strconv.Atoi(parts[hostPortIdx])
|
|
if err != nil || hostPort < 1 || hostPort > 65535 {
|
|
return fmt.Errorf("invalid host port number: %s", parts[hostPortIdx])
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateDynamicForward validates dynamic port forwarding specification
|
|
func validateDynamicForward(forward string) error {
|
|
if forward == "" {
|
|
return nil // Dynamic forwarding is optional
|
|
}
|
|
|
|
// Support multiple forwards separated by comma
|
|
forwards := strings.Split(forward, ",")
|
|
for _, fwd := range forwards {
|
|
fwd = strings.TrimSpace(fwd)
|
|
if fwd == "" {
|
|
continue
|
|
}
|
|
|
|
// Format: [bind_address:]port
|
|
parts := strings.Split(fwd, ":")
|
|
if len(parts) > 2 {
|
|
return fmt.Errorf("invalid format, expected [bind_address:]port")
|
|
}
|
|
|
|
var portStr string
|
|
if len(parts) == 1 {
|
|
// Just port
|
|
portStr = parts[0]
|
|
} else {
|
|
// bind_address:port
|
|
if parts[0] != "" && parts[0] != "*" {
|
|
if err := validateBindAddress(parts[0]); err != nil {
|
|
return fmt.Errorf("invalid bind address: %w", err)
|
|
}
|
|
}
|
|
portStr = parts[1]
|
|
}
|
|
|
|
// Validate port number
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return fmt.Errorf("invalid port number: %s", portStr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateBindAddress validates a bind address (IP, hostname, or *)
|
|
func validateBindAddress(address string) error {
|
|
if address == "" || address == "*" {
|
|
return nil // Empty or wildcard is valid
|
|
}
|
|
|
|
// Check for spaces
|
|
if strings.Contains(address, " ") {
|
|
return fmt.Errorf("address cannot contain spaces")
|
|
}
|
|
|
|
// Try to parse as IP address first (including IPv6)
|
|
if net.ParseIP(address) != nil {
|
|
return nil
|
|
}
|
|
|
|
// Validate as hostname with relaxed rules
|
|
return validateBindHostname(address)
|
|
}
|
|
|
|
// isNumericDottedFormat checks if the address looks like an IP address (contains only dots and digits)
|
|
func isNumericDottedFormat(address string) bool {
|
|
for _, ch := range address {
|
|
if ch != '.' && (ch < '0' || ch > '9') {
|
|
return false
|
|
}
|
|
}
|
|
return strings.Contains(address, ".")
|
|
}
|
|
|
|
// validateBindHostname validates a hostname for bind address (more permissive than regular hostname)
|
|
func validateBindHostname(address string) error {
|
|
// Check for invalid characters using a single check
|
|
if strings.ContainsAny(address, invalidAddressChars) {
|
|
return fmt.Errorf("address contains invalid characters")
|
|
}
|
|
|
|
// Check hostname format
|
|
if strings.HasPrefix(address, ".") || strings.HasSuffix(address, ".") {
|
|
return fmt.Errorf("address cannot start or end with a dot")
|
|
}
|
|
|
|
if strings.HasPrefix(address, "-") || strings.HasSuffix(address, "-") {
|
|
return fmt.Errorf("address cannot start or end with hyphen")
|
|
}
|
|
|
|
// Check for consecutive dots
|
|
if strings.Contains(address, "..") {
|
|
return fmt.Errorf("address cannot contain consecutive dots")
|
|
}
|
|
|
|
// If it looks like an IP address (contains only dots and digits), validate it more strictly
|
|
if isNumericDottedFormat(address) {
|
|
// Check if all segments are valid numbers
|
|
segments := strings.Split(address, ".")
|
|
// IPv4 should have exactly 4 segments
|
|
if len(segments) == 4 {
|
|
for _, seg := range segments {
|
|
if seg == "" {
|
|
return fmt.Errorf("invalid IP address format")
|
|
}
|
|
num, err := strconv.Atoi(seg)
|
|
if err != nil || num < 0 || num > 255 {
|
|
return fmt.Errorf("invalid IP address format")
|
|
}
|
|
}
|
|
return nil // Valid IPv4
|
|
}
|
|
// If it's not 4 segments but looks numeric, it's invalid
|
|
return fmt.Errorf("invalid address format")
|
|
}
|
|
|
|
// Check each label for hyphens at start/end
|
|
if strings.Contains(address, ".") {
|
|
return validateAddressLabels(address)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateAddressLabels validates labels in a bind address
|
|
func validateAddressLabels(address string) error {
|
|
labels := strings.Split(address, ".")
|
|
for _, label := range labels {
|
|
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
|
return fmt.Errorf("address label cannot start or end with hyphen")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stripColorTags removes tview color tags from a string
|
|
func stripColorTags(s string) string {
|
|
// Remove all tview color tags like [red], [-], [yellow], etc.
|
|
colorTagRegex := regexp.MustCompile(`\[[^\]]*\]`)
|
|
return colorTagRegex.ReplaceAllString(s, "")
|
|
}
|