package config import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // ExtraPort is a TCP or UDP port that the firewall should allow on the WAN interface. type ExtraPort struct { Port int `yaml:"port" json:"port"` Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"` // "tcp" (default) or "udp" Label string `yaml:"label,omitempty" json:"label,omitempty"` } // UnmarshalYAML handles both plain integers (legacy: extraPorts: [22]) and structured entries // (extraPorts: [{port: 22, protocol: tcp, label: SSH}]). func (p *ExtraPort) UnmarshalYAML(value *yaml.Node) error { if value.Kind == yaml.ScalarNode { var port int if err := value.Decode(&port); err != nil { return fmt.Errorf("extraPort: expected integer, got %q", value.Value) } p.Port = port return nil } type alias ExtraPort return value.Decode((*alias)(p)) } // UnmarshalJSON handles both plain integers (legacy: extraPorts: [22]) and structured entries. func (p *ExtraPort) UnmarshalJSON(data []byte) error { if len(data) > 0 && data[0] != '{' { var port int if err := json.Unmarshal(data, &port); err != nil { return fmt.Errorf("extraPort: expected integer or object, got: %s", data) } p.Port = port return nil } type alias ExtraPort var a alias if err := json.Unmarshal(data, &a); err != nil { return err } *p = ExtraPort(a) return nil } // SplitExtraPorts separates ExtraPorts into TCP and UDP slices. // Ports with no Protocol or Protocol "tcp" are TCP; Protocol "udp" are UDP. func SplitExtraPorts(ports []ExtraPort) (tcp []int, udp []int) { for _, p := range ports { if strings.EqualFold(p.Protocol, "udp") { udp = append(udp, p.Port) } else { tcp = append(tcp, p.Port) } } return } // HAProxyCustomRoute defines a custom TCP proxy rule for non-Wild Cloud services type HAProxyCustomRoute struct { Name string `yaml:"name" json:"name"` Port int `yaml:"port" json:"port"` Backend string `yaml:"backend" json:"backend"` } // DHCPStaticLease defines a static DHCP address assignment type DHCPStaticLease struct { MAC string `yaml:"mac" json:"mac"` IP string `yaml:"ip" json:"ip"` Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"` } // GlobalConfig represents the main configuration structure type GlobalConfig struct { Operator struct { Email string `yaml:"email,omitempty" json:"email,omitempty"` } `yaml:"operator,omitempty" json:"operator,omitempty"` Cloud struct { Central struct { Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // e.g. "central.payne.io" } `yaml:"central,omitempty" json:"central,omitempty"` Router struct { IP string `yaml:"ip,omitempty" json:"ip,omitempty"` DynamicDns string `yaml:"dynamicDns,omitempty" json:"dynamicDns,omitempty"` } `yaml:"router,omitempty" json:"router,omitempty"` Dnsmasq struct { IP string `yaml:"ip,omitempty" json:"ip,omitempty"` Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` DHCP struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` RangeStart string `yaml:"rangeStart,omitempty" json:"rangeStart,omitempty"` RangeEnd string `yaml:"rangeEnd,omitempty" json:"rangeEnd,omitempty"` LeaseTime string `yaml:"leaseTime,omitempty" json:"leaseTime,omitempty"` Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"` StaticLeases []DHCPStaticLease `yaml:"staticLeases,omitempty" json:"staticLeases,omitempty"` } `yaml:"dhcp,omitempty" json:"dhcp,omitempty"` } `yaml:"dnsmasq,omitempty" json:"dnsmasq,omitempty"` HAProxy struct { CustomRoutes []HAProxyCustomRoute `yaml:"customRoutes,omitempty" json:"customRoutes,omitempty"` } `yaml:"haproxy,omitempty" json:"haproxy,omitempty"` Nftables struct { Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` WANInterface string `yaml:"wanInterface,omitempty" json:"wanInterface,omitempty"` ExtraPorts []ExtraPort `yaml:"extraPorts,omitempty" json:"extraPorts,omitempty"` } `yaml:"nftables,omitempty" json:"nftables,omitempty"` DDNS struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` Records []string `yaml:"records,omitempty" json:"records,omitempty"` IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"` } `yaml:"ddns,omitempty" json:"ddns,omitempty"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"` } // LoadGlobalConfig loads configuration from the specified path func LoadGlobalConfig(configPath string) (*GlobalConfig, error) { data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("reading config file %s: %w", configPath, err) } config := &GlobalConfig{} if err := yaml.Unmarshal(data, config); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } return config, nil } // SaveGlobalConfig saves the configuration to the specified path func SaveGlobalConfig(config *GlobalConfig, configPath string) error { // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return fmt.Errorf("creating config directory: %w", err) } data, err := yaml.Marshal(config) if err != nil { return fmt.Errorf("marshaling config: %w", err) } return os.WriteFile(configPath, data, 0644) } // IsEmpty checks if the configuration is empty or uninitialized func (c *GlobalConfig) IsEmpty() bool { if c == nil { return true } // Check if essential fields are empty return c.Cloud.Router.IP == "" && c.Operator.Email == "" } type NodeConfig struct { Role string `yaml:"role" json:"role"` Interface string `yaml:"interface" json:"interface"` Disk string `yaml:"disk" json:"disk"` CurrentIp string `yaml:"currentIp" json:"currentIp"` } type InstanceConfig struct { Operator struct { Email string `yaml:"email" json:"email"` } `yaml:"operator" json:"operator"` Cloud struct { BaseDomain string `yaml:"baseDomain" json:"baseDomain"` Domain string `yaml:"domain" json:"domain"` InternalDomain string `yaml:"internalDomain" json:"internalDomain"` DHCPRange string `yaml:"dhcpRange" json:"dhcpRange"` NFS struct { Host string `yaml:"host" json:"host"` MediaPath string `yaml:"mediaPath" json:"mediaPath"` StorageCapacity string `yaml:"storageCapacity" json:"storageCapacity"` } `yaml:"nfs" json:"nfs"` DockerRegistryHost string `yaml:"dockerRegistryHost" json:"dockerRegistryHost"` } `yaml:"cloud" json:"cloud"` Cluster struct { Name string `yaml:"name" json:"name"` LoadBalancerIp string `yaml:"loadBalancerIp" json:"loadBalancerIp"` IpAddressPool string `yaml:"ipAddressPool" json:"ipAddressPool"` HostnamePrefix string `yaml:"hostnamePrefix" json:"hostnamePrefix"` CertManager struct { Cloudflare struct { Domain string `yaml:"domain" json:"domain"` } `yaml:"cloudflare" json:"cloudflare"` } `yaml:"certManager" json:"certManager"` ExternalDns struct { OwnerId string `yaml:"ownerId" json:"ownerId"` } `yaml:"externalDns" json:"externalDns"` InternalDns struct { ExternalResolver string `yaml:"externalResolver" json:"externalResolver"` } `yaml:"internalDns" json:"internalDns"` DockerRegistry struct { Storage string `yaml:"storage" json:"storage"` } `yaml:"dockerRegistry" json:"dockerRegistry"` Nodes struct { Talos struct { Version string `yaml:"version" json:"version"` SchematicId string `yaml:"schematicId" json:"schematicId"` } `yaml:"talos" json:"talos"` Control struct { Vip string `yaml:"vip" json:"vip"` } `yaml:"control" json:"control"` Active map[string]NodeConfig `yaml:"active" json:"active"` } `yaml:"nodes" json:"nodes"` } `yaml:"cluster" json:"cluster"` Apps map[string]any `yaml:"apps" json:"apps"` } func LoadCloudConfig(configPath string) (*InstanceConfig, error) { data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("reading config file %s: %w", configPath, err) } config := &InstanceConfig{} if err := yaml.Unmarshal(data, config); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } return config, nil } func SaveCloudConfig(config *InstanceConfig, configPath string) error { // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return fmt.Errorf("creating config directory: %w", err) } data, err := yaml.Marshal(config) if err != nil { return fmt.Errorf("marshaling config: %w", err) } return os.WriteFile(configPath, data, 0644) } // DeepMerge recursively merges src into dst, with src values taking precedence. // Nested maps are merged recursively; all other types are overwritten by src. func DeepMerge(dst, src map[string]any) map[string]any { result := make(map[string]any) for k, v := range dst { result[k] = v } for k, v := range src { if srcMap, ok := v.(map[string]any); ok { if dstMap, ok := result[k].(map[string]any); ok { result[k] = DeepMerge(dstMap, srcMap) continue } } result[k] = v } return result } // LoadMergedInstanceConfig loads the global config as a base and merges the // instance config on top. Returns the merged result as an untyped map suitable // for passing to gomplate as template context. // If the global config is missing, returns the instance config alone. // Assembles apps.* from config/apps/*/config.yaml files. func LoadMergedInstanceConfig(dataDir, instanceName string) (map[string]any, error) { instanceConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml") globalConfigPath := filepath.Join(dataDir, "state.yaml") instanceData, err := os.ReadFile(instanceConfigPath) if err != nil { return nil, fmt.Errorf("reading instance config %s: %w", instanceConfigPath, err) } var instanceMap map[string]any if err := yaml.Unmarshal(instanceData, &instanceMap); err != nil { return nil, fmt.Errorf("parsing instance config: %w", err) } if instanceMap == nil { instanceMap = make(map[string]any) } // Assemble apps.* map from per-app config files appsConfigDir := filepath.Join(dataDir, "instances", instanceName, "config", "apps") if entries, err := os.ReadDir(appsConfigDir); err == nil { appsMap := make(map[string]any) for _, entry := range entries { if !entry.IsDir() { continue } appName := entry.Name() appConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "apps", appName, "config.yaml") appData, err := os.ReadFile(appConfigPath) if err != nil { continue } var appConfig map[string]any if err := yaml.Unmarshal(appData, &appConfig); err == nil && appConfig != nil { appsMap[appName] = appConfig } } if len(appsMap) > 0 { instanceMap["apps"] = appsMap } } globalData, err := os.ReadFile(globalConfigPath) if err != nil { if os.IsNotExist(err) { slog.Warn("no global config found, using instance config only", "path", globalConfigPath) return instanceMap, nil } return nil, fmt.Errorf("reading global config %s: %w", globalConfigPath, err) } var globalMap map[string]any if err := yaml.Unmarshal(globalData, &globalMap); err != nil { return nil, fmt.Errorf("parsing global config: %w", err) } return DeepMerge(globalMap, instanceMap), nil }