feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
328
internal/config/config.go
Normal file
328
internal/config/config.go
Normal file
@@ -0,0 +1,328 @@
|
||||
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, "config.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
|
||||
}
|
||||
Reference in New Issue
Block a user