Files
kportal/internal/config/config.go
T
lukaszraczylo d9888f1a56 Cleanup (#14)
* Codebase cleanup
2025-12-09 01:06:38 +00:00

403 lines
13 KiB
Go

package config
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// ErrConfigNotFound is returned when the configuration file does not exist
var ErrConfigNotFound = fmt.Errorf("config file not found")
const (
// maxConfigSize is the maximum allowed configuration file size (10MB)
maxConfigSize = 10 * 1024 * 1024
// Default health check settings
DefaultHealthCheckInterval = 3 * time.Second // How often to check connection health
DefaultHealthCheckTimeout = 2 * time.Second // Timeout for health check probes
DefaultHealthCheckMethod = "data-transfer" // More reliable than tcp-dial
DefaultMaxConnectionAge = 25 * time.Minute // Reconnect before k8s 30min timeout
DefaultMaxIdleTime = 10 * time.Minute // Reconnect if no activity
// Default reliability settings
DefaultTCPKeepalive = 30 * time.Second // OS-level TCP keepalive interval
DefaultDialTimeout = 30 * time.Second // Connection establishment timeout
DefaultWatchdogPeriod = 30 * time.Second // Goroutine health check interval
// Default HTTP logging settings
DefaultHTTPLogMaxBodySize = 1024 * 1024 // 1MB max body size for logging
)
// Config represents the root configuration structure from .kportal.yaml
type Config struct {
Contexts []Context `yaml:"contexts"`
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
MDNS *MDNSSpec `yaml:"mdns,omitempty"`
}
// MDNSSpec configures mDNS (multicast DNS) hostname publishing
// When enabled, forwards with aliases can be accessed via <alias>.local hostnames
type MDNSSpec struct {
Enabled bool `yaml:"enabled"` // Enable mDNS hostname publishing
}
// HealthCheckSpec configures health check behavior
type HealthCheckSpec struct {
Interval string `yaml:"interval,omitempty"` // e.g., "3s", "5s"
Timeout string `yaml:"timeout,omitempty"` // e.g., "2s"
Method string `yaml:"method,omitempty"` // "tcp-dial" | "data-transfer"
MaxConnectionAge string `yaml:"maxConnectionAge,omitempty"` // e.g., "25m" - reconnect before k8s timeout
MaxIdleTime string `yaml:"maxIdleTime,omitempty"` // e.g., "10m" - reconnect if no activity
}
// ReliabilitySpec configures connection reliability features
type ReliabilitySpec struct {
TCPKeepalive string `yaml:"tcpKeepalive,omitempty"` // e.g., "30s" - OS-level keepalive
DialTimeout string `yaml:"dialTimeout,omitempty"` // e.g., "30s" - connection dial timeout
RetryOnStale bool `yaml:"retryOnStale,omitempty"` // Auto-reconnect on stale detection
WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"` // e.g., "30s" - goroutine watchdog interval
}
// parseDurationOrDefault parses a duration string and returns the default if empty or invalid.
func parseDurationOrDefault(value string, defaultDur time.Duration) time.Duration {
if value == "" {
return defaultDur
}
if d, err := time.ParseDuration(value); err == nil {
return d
}
return defaultDur
}
// GetHealthCheckIntervalOrDefault returns the health check interval or default value
func (c *Config) GetHealthCheckIntervalOrDefault() time.Duration {
if c.HealthCheck == nil {
return DefaultHealthCheckInterval
}
return parseDurationOrDefault(c.HealthCheck.Interval, DefaultHealthCheckInterval)
}
// GetHealthCheckTimeoutOrDefault returns the health check timeout or default value
func (c *Config) GetHealthCheckTimeoutOrDefault() time.Duration {
if c.HealthCheck == nil {
return DefaultHealthCheckTimeout
}
return parseDurationOrDefault(c.HealthCheck.Timeout, DefaultHealthCheckTimeout)
}
// GetHealthCheckMethod returns the health check method or default
func (c *Config) GetHealthCheckMethod() string {
if c.HealthCheck != nil && c.HealthCheck.Method != "" {
return c.HealthCheck.Method
}
return DefaultHealthCheckMethod
}
// GetMaxConnectionAge returns the max connection age or default
func (c *Config) GetMaxConnectionAge() time.Duration {
if c.HealthCheck == nil {
return DefaultMaxConnectionAge
}
return parseDurationOrDefault(c.HealthCheck.MaxConnectionAge, DefaultMaxConnectionAge)
}
// GetMaxIdleTime returns the max idle time or default
func (c *Config) GetMaxIdleTime() time.Duration {
if c.HealthCheck == nil {
return DefaultMaxIdleTime
}
return parseDurationOrDefault(c.HealthCheck.MaxIdleTime, DefaultMaxIdleTime)
}
// GetTCPKeepalive returns the TCP keepalive duration or default
func (c *Config) GetTCPKeepalive() time.Duration {
if c.Reliability == nil {
return DefaultTCPKeepalive
}
return parseDurationOrDefault(c.Reliability.TCPKeepalive, DefaultTCPKeepalive)
}
// GetRetryOnStale returns whether to retry on stale connections
func (c *Config) GetRetryOnStale() bool {
if c.Reliability != nil {
return c.Reliability.RetryOnStale
}
return true // Default: enabled
}
// GetWatchdogPeriod returns the goroutine watchdog check period or default
func (c *Config) GetWatchdogPeriod() time.Duration {
if c.Reliability == nil {
return DefaultWatchdogPeriod
}
return parseDurationOrDefault(c.Reliability.WatchdogPeriod, DefaultWatchdogPeriod)
}
// GetDialTimeout returns the connection dial timeout or default
func (c *Config) GetDialTimeout() time.Duration {
if c.Reliability == nil {
return DefaultDialTimeout
}
return parseDurationOrDefault(c.Reliability.DialTimeout, DefaultDialTimeout)
}
// IsMDNSEnabled returns whether mDNS hostname publishing is enabled
func (c *Config) IsMDNSEnabled() bool {
return c.MDNS != nil && c.MDNS.Enabled
}
// Context represents a Kubernetes context with its namespaces
type Context struct {
Name string `yaml:"name"`
Namespaces []Namespace `yaml:"namespaces"`
}
// Namespace represents a Kubernetes namespace with its forwards
type Namespace struct {
Name string `yaml:"name"`
Forwards []Forward `yaml:"forwards"`
}
// HTTPLogSpec configures HTTP traffic logging for a forward
type HTTPLogSpec struct {
Enabled bool `yaml:"enabled"` // Enable HTTP logging
LogFile string `yaml:"logFile,omitempty"` // Output file (empty = stdout)
MaxBodySize int `yaml:"maxBodySize,omitempty"` // Max body size to log (default 1MB)
IncludeHeaders bool `yaml:"includeHeaders,omitempty"` // Include headers in log
FilterPath string `yaml:"filterPath,omitempty"` // Optional glob filter for paths
}
// UnmarshalYAML implements custom unmarshaling to support both bool and struct formats
// Allows: httpLog: true OR httpLog: { enabled: true, ... }
func (h *HTTPLogSpec) UnmarshalYAML(unmarshal func(interface{}) error) error {
// First try to unmarshal as a boolean
var boolVal bool
if err := unmarshal(&boolVal); err == nil {
h.Enabled = boolVal
return nil
}
// Otherwise try to unmarshal as a struct
type httpLogSpecAlias HTTPLogSpec // Use alias to avoid infinite recursion
var spec httpLogSpecAlias
if err := unmarshal(&spec); err != nil {
return err
}
*h = HTTPLogSpec(spec)
return nil
}
// Forward represents a single port-forward configuration
type Forward struct {
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
Protocol string `yaml:"protocol"` // tcp or udp
Port int `yaml:"port"` // Remote port
LocalPort int `yaml:"localPort"` // Local port
Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward
HTTPLog *HTTPLogSpec `yaml:"httpLog,omitempty"` // Optional HTTP traffic logging
// Runtime fields (not in YAML)
contextName string
namespaceName string
}
// ID returns a unique identifier for this forward configuration.
// Format: alias:localPort (if alias provided) or context/namespace/resource:localPort
func (f *Forward) ID() string {
if f.Alias != "" {
return fmt.Sprintf("%s:%d", f.Alias, f.LocalPort)
}
return fmt.Sprintf("%s/%s/%s:%d", f.contextName, f.namespaceName, f.Resource, f.LocalPort)
}
// String returns a human-readable representation of the forward.
// Format: alias:port→localPort (if alias provided) or context/namespace/resource:port→localPort
func (f *Forward) String() string {
if f.Alias != "" {
return fmt.Sprintf("%s:%d→%d", f.Alias, f.Port, f.LocalPort)
}
if f.Selector != "" {
return fmt.Sprintf("%s/%s/%s[%s]:%d→%d",
f.contextName, f.namespaceName, f.Resource, f.Selector, f.Port, f.LocalPort)
}
return fmt.Sprintf("%s/%s/%s:%d→%d",
f.contextName, f.namespaceName, f.Resource, f.Port, f.LocalPort)
}
// SetContext sets the context and namespace names for this forward.
// This is used during config parsing to populate runtime fields.
func (f *Forward) SetContext(ctx, ns string) {
f.contextName = ctx
f.namespaceName = ns
}
// GetContext returns the context name for this forward.
func (f *Forward) GetContext() string {
return f.contextName
}
// GetNamespace returns the namespace name for this forward.
func (f *Forward) GetNamespace() string {
return f.namespaceName
}
// IsHTTPLogEnabled returns true if HTTP logging is enabled for this forward
func (f *Forward) IsHTTPLogEnabled() bool {
return f.HTTPLog != nil && f.HTTPLog.Enabled
}
// GetHTTPLogMaxBodySize returns the max body size for HTTP logging
func (f *Forward) GetHTTPLogMaxBodySize() int {
if f.HTTPLog == nil || f.HTTPLog.MaxBodySize <= 0 {
return DefaultHTTPLogMaxBodySize
}
return f.HTTPLog.MaxBodySize
}
// GetMDNSAlias returns the alias to use for mDNS hostname registration.
// If an explicit alias is set, it returns that.
// Otherwise, it generates one from the resource name (e.g., "service/logto" -> "logto").
func (f *Forward) GetMDNSAlias() string {
if f.Alias != "" {
return f.Alias
}
// Generate alias from resource name
// Format is "type/name" (e.g., "service/logto", "pod/my-app")
parts := strings.SplitN(f.Resource, "/", 2)
if len(parts) == 2 && parts[1] != "" {
return parts[1]
}
// Fallback: can't generate a valid alias (e.g., "pod" with selector)
return ""
}
// LoadConfig loads and parses the configuration file from the given path.
func LoadConfig(path string) (*Config, error) {
// Validate file size before reading
fileInfo, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrConfigNotFound
}
return nil, fmt.Errorf("failed to stat config file: %w", err)
}
if fileInfo.Size() > maxConfigSize {
return nil, fmt.Errorf("config file too large: %d bytes (max %d)", fileInfo.Size(), maxConfigSize)
}
// #nosec G304 -- path is validated in main.go (no system dirs, absolute path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
return ParseConfig(data)
}
// ParseConfig parses YAML configuration data into a Config struct.
// It uses strict parsing that rejects unknown keys to catch typos.
func ParseConfig(data []byte) (*Config, error) {
var cfg Config
// Use decoder with KnownFields to reject unknown keys (catches typos)
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&cfg); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Populate runtime fields (context and namespace names)
for i := range cfg.Contexts {
ctx := &cfg.Contexts[i]
for j := range ctx.Namespaces {
ns := &ctx.Namespaces[j]
for k := range ns.Forwards {
fwd := &ns.Forwards[k]
fwd.SetContext(ctx.Name, ns.Name)
}
}
}
return &cfg, nil
}
// GetAllForwards returns a flat list of all forwards across all contexts and namespaces.
func (c *Config) GetAllForwards() []Forward {
var forwards []Forward
for _, ctx := range c.Contexts {
for _, ns := range ctx.Namespaces {
forwards = append(forwards, ns.Forwards...)
}
}
return forwards
}
// NewEmptyConfig returns a minimal empty configuration with no forwards.
// This is used when creating a new config file for the first time.
func NewEmptyConfig() *Config {
return &Config{
Contexts: []Context{},
}
}
// IsEmpty returns true if the configuration has no forwards defined.
func (c *Config) IsEmpty() bool {
return len(c.Contexts) == 0 || len(c.GetAllForwards()) == 0
}
// CreateEmptyConfigFile creates a new empty configuration file at the given path.
// Returns an error if the file already exists or cannot be created.
func CreateEmptyConfigFile(path string) error {
// Check if file already exists
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("config file already exists: %s", path)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to check config file: %w", err)
}
cfg := NewEmptyConfig()
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal empty config: %w", err)
}
// Add a helpful comment header
header := `# kportal configuration file
# Add port forwards using the 'n' key in the TUI, or manually add them below.
#
# Example forward:
# contexts:
# - name: my-cluster
# namespaces:
# - name: default
# forwards:
# - resource: service/my-service
# protocol: tcp
# port: 8080
# localPort: 8080
#
`
content := header + string(data)
// Write with restrictive permissions (0600)
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}