Files
wild-central/internal/config/config.go
Paul Payne 428d47f876 Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture:
- Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces
- Add locked modifyState helper to fix state.yaml read-modify-write race condition
- Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface
- Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease
- Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate

Complexity reduction:
- haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers)
- reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig)
- AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia)

Test coverage improvements:
- reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration)
- dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries)
- config: 67.3% → 89.8% (DHCP static lease mutation tests)
2026-07-14 04:21:30 +00:00

187 lines
7.1 KiB
Go

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"`
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
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)
}