325 lines
11 KiB
Go
325 lines
11 KiB
Go
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"`
|
|
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"`
|
|
}
|
|
|
|
// LoadState loads state from the specified path
|
|
func LoadState(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
|
|
}
|
|
|
|
// SaveState saves the state to the specified path
|
|
func SaveState(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.Central.Domain == "" && 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
|
|
}
|