package config import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "github.com/wild-cloud/wild-central/internal/storage" ) // 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"` } // State represents Wild Central's runtime state — operator settings, networking // configuration, DDNS, DHCP, etc. Persisted in {dataDir}/state.yaml and mutated // via the API. This is NOT boot-time config (which comes from env vars). type State 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"` IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"` } `yaml:"ddns,omitempty" json:"ddns,omitempty"` Authelia struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io) DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor SMTP struct { Host string `yaml:"host,omitempty" json:"host,omitempty"` // e.g. smtp.gmail.com Port int `yaml:"port,omitempty" json:"port,omitempty"` // e.g. 587 Username string `yaml:"username,omitempty" json:"username,omitempty"` // SMTP login username Sender string `yaml:"sender,omitempty" json:"sender,omitempty"` // From address (e.g. auth@payne.io) } `yaml:"smtp,omitempty" json:"smtp,omitempty"` } `yaml:"authelia,omitempty" json:"authelia,omitempty"` DNSFilter struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` IntervalHours int `yaml:"intervalHours,omitempty" json:"intervalHours,omitempty"` // update interval, default 24 } `yaml:"dnsFilter,omitempty" json:"dnsFilter,omitempty"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"` } // AddDHCPStaticLease adds or replaces a static lease entry (matched by MAC). func (s *State) AddDHCPStaticLease(lease DHCPStaticLease) { for i, l := range s.Cloud.Dnsmasq.DHCP.StaticLeases { if l.MAC == lease.MAC { s.Cloud.Dnsmasq.DHCP.StaticLeases[i] = lease return } } s.Cloud.Dnsmasq.DHCP.StaticLeases = append(s.Cloud.Dnsmasq.DHCP.StaticLeases, lease) } // RemoveDHCPStaticLease removes a static lease entry by MAC. Returns true if found. func (s *State) RemoveDHCPStaticLease(mac string) bool { leases := s.Cloud.Dnsmasq.DHCP.StaticLeases for i, l := range leases { if l.MAC == mac { s.Cloud.Dnsmasq.DHCP.StaticLeases = append(leases[:i], leases[i+1:]...) return true } } return false } // LoadState loads state from the specified path. If the primary file is // corrupted, falls back to the .bak backup if one exists. func LoadState(configPath string) (*State, error) { state, err := loadStateFrom(configPath) if err == nil { return state, nil } // Primary failed — try backup bakPath := configPath + ".bak" bakState, bakErr := loadStateFrom(bakPath) if bakErr == nil { slog.Warn("loaded state from backup (primary corrupted)", "component", "config", "path", configPath, "error", err) return bakState, nil } return nil, fmt.Errorf("reading config file %s: %w", configPath, err) } func loadStateFrom(path string) (*State, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } config := &State{} if err := yaml.Unmarshal(data, config); err != nil { return nil, fmt.Errorf("parsing %s: %w", path, err) } return config, nil } // SaveState saves the state to the specified path. Backs up the current // file to .bak before writing so LoadState can recover from corruption. func SaveState(config *State, configPath string) error { 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) } // Backup current state before overwriting if storage.FileExists(configPath) { _ = storage.CopyFile(configPath, configPath+".bak") } return storage.WriteFileAtomic(configPath, data, 0644) }