package config import ( "encoding/json" "fmt" "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"` } // 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"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"` } // LoadState loads state from the specified path func LoadState(configPath string) (*State, error) { data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("reading config file %s: %w", configPath, err) } config := &State{} 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 *State, 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 *State) IsEmpty() bool { if c == nil { return true } // Check if essential fields are empty return c.Cloud.Central.Domain == "" && c.Operator.Email == "" }