mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-06 23:13:39 +00:00
a297ba7073
When user starts kportal for the first time, and there is no config file, kportal should create an empty config file with default values and empty forwarding rules, so that user can easily edit the config file and add their own rules.
373 lines
10 KiB
Go
373 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
MinPort = 1
|
|
MaxPort = 65535
|
|
)
|
|
|
|
// IsValidPort returns true if the port number is within the valid range (1-65535).
|
|
func IsValidPort(port int) bool {
|
|
return port >= MinPort && port <= MaxPort
|
|
}
|
|
|
|
// ValidationError represents a configuration validation error with context.
|
|
type ValidationError struct {
|
|
Field string // The field that failed validation
|
|
Message string // Error message
|
|
Context map[string]string // Additional context information
|
|
}
|
|
|
|
// Error implements the error interface.
|
|
func (e ValidationError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
// Validator validates configuration files.
|
|
type Validator struct{}
|
|
|
|
// NewValidator creates a new Validator instance.
|
|
func NewValidator() *Validator {
|
|
return &Validator{}
|
|
}
|
|
|
|
// ValidateConfig validates the entire configuration and returns all errors found.
|
|
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
|
return v.ValidateConfigWithOptions(cfg, false)
|
|
}
|
|
|
|
// ValidateConfigWithOptions validates configuration with configurable strictness.
|
|
// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed.
|
|
// This is useful for newly created config files where the user will add forwards via the TUI.
|
|
func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError {
|
|
var errs []ValidationError
|
|
|
|
if cfg == nil {
|
|
return []ValidationError{{
|
|
Field: "config",
|
|
Message: "Configuration is nil",
|
|
}}
|
|
}
|
|
|
|
// If empty configs are allowed and this config is empty, skip structure validation
|
|
if allowEmpty && cfg.IsEmpty() {
|
|
// Still validate health check and reliability if present (they don't require forwards)
|
|
return errs
|
|
}
|
|
|
|
// Validate structure
|
|
errs = append(errs, v.validateStructure(cfg)...)
|
|
|
|
// Validate each forward
|
|
for _, ctx := range cfg.Contexts {
|
|
for _, ns := range ctx.Namespaces {
|
|
for _, fwd := range ns.Forwards {
|
|
errs = append(errs, v.validateForward(&fwd)...)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for duplicate local ports
|
|
errs = append(errs, v.validateDuplicatePorts(cfg)...)
|
|
|
|
// Validate mDNS configuration
|
|
if cfg.IsMDNSEnabled() {
|
|
errs = append(errs, v.validateMDNS(cfg)...)
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// validateStructure validates the basic structure of the configuration.
|
|
func (v *Validator) validateStructure(cfg *Config) []ValidationError {
|
|
var errs []ValidationError
|
|
|
|
if len(cfg.Contexts) == 0 {
|
|
errs = append(errs, ValidationError{
|
|
Field: "contexts",
|
|
Message: "Configuration must have at least one context",
|
|
})
|
|
return errs
|
|
}
|
|
|
|
for i, ctx := range cfg.Contexts {
|
|
if ctx.Name == "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: fmt.Sprintf("contexts[%d].name", i),
|
|
Message: "Context name cannot be empty",
|
|
})
|
|
}
|
|
|
|
if len(ctx.Namespaces) == 0 {
|
|
errs = append(errs, ValidationError{
|
|
Field: fmt.Sprintf("contexts[%d].namespaces", i),
|
|
Message: fmt.Sprintf("Context '%s' must have at least one namespace", ctx.Name),
|
|
})
|
|
// Don't continue - still validate other aspects of the context if any
|
|
}
|
|
|
|
for j, ns := range ctx.Namespaces {
|
|
if ns.Name == "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: fmt.Sprintf("contexts[%d].namespaces[%d].name", i, j),
|
|
Message: fmt.Sprintf("Namespace name cannot be empty in context '%s'", ctx.Name),
|
|
})
|
|
}
|
|
|
|
if len(ns.Forwards) == 0 {
|
|
errs = append(errs, ValidationError{
|
|
Field: fmt.Sprintf("contexts[%d].namespaces[%d].forwards", i, j),
|
|
Message: fmt.Sprintf("Namespace '%s/%s' must have at least one forward", ctx.Name, ns.Name),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// validateForward validates a single forward configuration.
|
|
func (v *Validator) validateForward(fwd *Forward) []ValidationError {
|
|
var errs []ValidationError
|
|
|
|
// Validate resource
|
|
if fwd.Resource == "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "resource",
|
|
Message: fmt.Sprintf("Resource cannot be empty for forward %s", fwd.ID()),
|
|
})
|
|
} else {
|
|
errs = append(errs, v.validateResource(fwd)...)
|
|
}
|
|
|
|
// Validate protocol
|
|
if fwd.Protocol != "" && fwd.Protocol != "tcp" && fwd.Protocol != "udp" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "protocol",
|
|
Message: fmt.Sprintf("Invalid protocol '%s' for forward %s (must be 'tcp' or 'udp')", fwd.Protocol, fwd.ID()),
|
|
})
|
|
}
|
|
|
|
// Validate ports
|
|
if fwd.Port < MinPort || fwd.Port > MaxPort {
|
|
errs = append(errs, ValidationError{
|
|
Field: "port",
|
|
Message: fmt.Sprintf("Invalid port %d for forward %s (must be between %d and %d)", fwd.Port, fwd.ID(), MinPort, MaxPort),
|
|
})
|
|
}
|
|
|
|
if fwd.LocalPort < MinPort || fwd.LocalPort > MaxPort {
|
|
errs = append(errs, ValidationError{
|
|
Field: "localPort",
|
|
Message: fmt.Sprintf("Invalid localPort %d for forward %s (must be between %d and %d)", fwd.LocalPort, fwd.ID(), MinPort, MaxPort),
|
|
})
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// validateResource validates the resource field format and selector usage.
|
|
func (v *Validator) validateResource(fwd *Forward) []ValidationError {
|
|
var errs []ValidationError
|
|
|
|
parts := strings.SplitN(fwd.Resource, "/", 2)
|
|
resourceType := parts[0]
|
|
|
|
// Valid resource types: pod, service
|
|
if resourceType != "pod" && resourceType != "service" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "resource",
|
|
Message: fmt.Sprintf("Invalid resource type '%s' for forward %s (must be 'pod' or 'service')", resourceType, fwd.ID()),
|
|
})
|
|
return errs
|
|
}
|
|
|
|
// For pod resources
|
|
if resourceType == "pod" {
|
|
if len(parts) == 2 {
|
|
// pod/name format - should not have selector
|
|
if fwd.Selector != "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "selector",
|
|
Message: fmt.Sprintf("Forward %s uses explicit pod name (%s) and should not have a selector", fwd.ID(), fwd.Resource),
|
|
})
|
|
}
|
|
|
|
// Validate pod name is not empty
|
|
if parts[1] == "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "resource",
|
|
Message: fmt.Sprintf("Pod name cannot be empty for forward %s", fwd.ID()),
|
|
})
|
|
}
|
|
} else {
|
|
// pod (no name) - must have selector
|
|
if fwd.Selector == "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "selector",
|
|
Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// For service resources
|
|
if resourceType == "service" {
|
|
if len(parts) < 2 || parts[1] == "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "resource",
|
|
Message: fmt.Sprintf("Service name cannot be empty for forward %s", fwd.ID()),
|
|
})
|
|
}
|
|
|
|
if fwd.Selector != "" {
|
|
errs = append(errs, ValidationError{
|
|
Field: "selector",
|
|
Message: fmt.Sprintf("Forward %s uses service resource and should not have a selector", fwd.ID()),
|
|
})
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// validateDuplicatePorts checks for duplicate local ports across all forwards.
|
|
func (v *Validator) validateDuplicatePorts(cfg *Config) []ValidationError {
|
|
var errs []ValidationError
|
|
|
|
portMap := make(map[int][]string) // port -> list of forward IDs
|
|
|
|
for _, ctx := range cfg.Contexts {
|
|
for _, ns := range ctx.Namespaces {
|
|
for _, fwd := range ns.Forwards {
|
|
portMap[fwd.LocalPort] = append(portMap[fwd.LocalPort], fwd.ID())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find duplicates
|
|
for port, forwards := range portMap {
|
|
if len(forwards) > 1 {
|
|
errs = append(errs, ValidationError{
|
|
Field: "localPort",
|
|
Message: fmt.Sprintf("Duplicate local port %d used by multiple forwards", port),
|
|
Context: map[string]string{
|
|
"port": fmt.Sprintf("%d", port),
|
|
"forwards": strings.Join(forwards, ", "),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// FormatValidationErrors formats validation errors into a human-readable string.
|
|
func FormatValidationErrors(errs []ValidationError) string {
|
|
if len(errs) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("\nConfiguration Validation Errors:\n")
|
|
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
|
|
|
|
for i, err := range errs {
|
|
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, err.Message))
|
|
if len(err.Context) > 0 {
|
|
for k, v := range err.Context {
|
|
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
|
|
}
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// validateMDNS validates mDNS configuration when enabled.
|
|
// It checks that aliases used for mDNS hostnames are valid and unique.
|
|
// This includes both explicit aliases and auto-generated ones from resource names.
|
|
func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
|
|
var errs []ValidationError
|
|
|
|
aliasMap := make(map[string][]string) // alias -> list of forward IDs using it
|
|
|
|
for _, ctx := range cfg.Contexts {
|
|
for _, ns := range ctx.Namespaces {
|
|
for _, fwd := range ns.Forwards {
|
|
// Get the mDNS alias (explicit or generated from resource name)
|
|
mdnsAlias := fwd.GetMDNSAlias()
|
|
if mdnsAlias == "" {
|
|
// No alias available (e.g., "pod" with selector only)
|
|
continue
|
|
}
|
|
|
|
// Validate alias is a valid hostname (RFC 1123)
|
|
if !isValidHostname(mdnsAlias) {
|
|
errs = append(errs, ValidationError{
|
|
Field: "alias",
|
|
Message: fmt.Sprintf("Forward %s has invalid mDNS hostname '%s' (must be a valid RFC 1123 hostname)", fwd.ID(), mdnsAlias),
|
|
})
|
|
}
|
|
|
|
aliasMap[mdnsAlias] = append(aliasMap[mdnsAlias], fwd.ID())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for duplicate aliases (would cause mDNS conflicts)
|
|
for alias, forwards := range aliasMap {
|
|
if len(forwards) > 1 {
|
|
errs = append(errs, ValidationError{
|
|
Field: "alias",
|
|
Message: fmt.Sprintf("Duplicate mDNS hostname '%s' used by multiple forwards (would cause conflict)", alias),
|
|
Context: map[string]string{
|
|
"alias": alias,
|
|
"forwards": strings.Join(forwards, ", "),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// isValidHostname checks if a string is a valid RFC 1123 hostname.
|
|
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
|
|
// and be 1-63 characters long.
|
|
func isValidHostname(name string) bool {
|
|
if len(name) == 0 || len(name) > 63 {
|
|
return false
|
|
}
|
|
|
|
// Must start with alphanumeric
|
|
if !isAlphanumeric(name[0]) {
|
|
return false
|
|
}
|
|
|
|
// Must end with alphanumeric
|
|
if !isAlphanumeric(name[len(name)-1]) {
|
|
return false
|
|
}
|
|
|
|
// Check all characters
|
|
for i := 0; i < len(name); i++ {
|
|
c := name[i]
|
|
if !isAlphanumeric(c) && c != '-' {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// isAlphanumeric returns true if the character is a letter or digit.
|
|
func isAlphanumeric(c byte) bool {
|
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
|
}
|