Files
wild-central/internal/ddns/cloudflare.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

391 lines
11 KiB
Go

package ddns
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
// Params holds the runtime parameters for DDNS sync — derived from system state
// (public domains, API token, interval) on each tick via ParamsFunc.
type Params struct {
APIToken string
Records []string
IntervalMinutes int
}
// ParamsFunc returns the current DDNS parameters. Called on each tick so the
// runner always has fresh state (new tokens, new public domains, etc.).
type ParamsFunc func() Params
// RecordStatus holds the last-known sync result for a single DNS record.
type RecordStatus struct {
Name string `json:"name"`
IP string `json:"ip,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// Status holds the current DDNS state for the status endpoint
type Status struct {
Enabled bool `json:"enabled"`
CurrentIP string `json:"currentIP"`
LastChecked time.Time `json:"lastChecked"`
LastUpdated time.Time `json:"lastUpdated"`
LastError string `json:"lastError,omitempty"`
Records []RecordStatus `json:"records,omitempty"`
}
// Runner manages the DDNS background goroutine and exposes current state.
// The runner pulls fresh parameters via ParamsFunc on each tick, so token
// and record changes take effect without restarting the goroutine.
type Runner struct {
mu sync.RWMutex
status Status
trigger chan struct{}
cancel context.CancelFunc
paramsFn ParamsFunc
ipFetcher func() (string, error) // injectable for tests
}
// NewRunner creates a new DDNS runner
func NewRunner() *Runner {
return &Runner{
trigger: make(chan struct{}, 1),
ipFetcher: getPublicIP,
}
}
// Start launches the DDNS goroutine with a params callback.
// Safe to call multiple times — cancels the previous run first.
func (rn *Runner) Start(parentCtx context.Context, paramsFn ParamsFunc) {
rn.mu.Lock()
if rn.cancel != nil {
rn.cancel()
rn.cancel = nil
}
rn.paramsFn = paramsFn
// Validate initial params
p := paramsFn()
if p.APIToken == "" || len(p.Records) == 0 {
rn.status.Enabled = false
rn.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(parentCtx)
rn.cancel = cancel
rn.status.Enabled = true
rn.mu.Unlock()
go rn.run(ctx)
}
// Stop cancels the running DDNS goroutine if one is active.
func (rn *Runner) Stop() {
rn.mu.Lock()
defer rn.mu.Unlock()
if rn.cancel != nil {
rn.cancel()
rn.cancel = nil
}
rn.status.Enabled = false
}
// GetStatus returns a snapshot of the current DDNS status
func (rn *Runner) GetStatus() Status {
rn.mu.RLock()
defer rn.mu.RUnlock()
return rn.status
}
// Trigger requests an immediate check with fresh params (non-blocking)
func (rn *Runner) Trigger() {
select {
case rn.trigger <- struct{}{}:
default:
}
}
// run is the DDNS polling loop. Calls paramsFn on each tick for fresh state.
func (rn *Runner) run(ctx context.Context) {
p := rn.paramsFn()
interval := time.Duration(p.IntervalMinutes) * time.Minute
if interval <= 0 {
interval = 5 * time.Minute
}
slog.Info("DDNS started", "component", "ddns", "records", p.Records, "interval", interval)
// Check immediately on start
rn.checkAndUpdate()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("DDNS stopped", "component", "ddns")
return
case <-ticker.C:
rn.checkAndUpdate()
case <-rn.trigger:
rn.checkAndUpdate()
}
}
}
// checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
// The updateRecord method short-circuits when the A record already matches,
// so there's no wasted API calls when nothing has changed.
func (rn *Runner) checkAndUpdate() {
rn.mu.Lock()
rn.status.LastChecked = time.Now()
rn.mu.Unlock()
p := rn.paramsFn()
if p.APIToken == "" || len(p.Records) == 0 {
slog.Debug("DDNS: no token or records, skipping", "component", "ddns")
return
}
ip, err := rn.ipFetcher()
if err != nil {
rn.mu.Lock()
rn.status.LastError = fmt.Sprintf("getting public IP: %v", err)
rn.mu.Unlock()
slog.Error("DDNS: failed to get public IP", "component", "ddns", "error", err)
return
}
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
cf := &cfClient{apiToken: p.APIToken}
var lastErr string
records := make([]RecordStatus, 0, len(p.Records))
for _, record := range p.Records {
if err := cf.updateRecord(record, ip); err != nil {
lastErr = fmt.Sprintf("updating %s: %v", record, err)
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err)
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})
} else {
records = append(records, RecordStatus{Name: record, IP: ip, OK: true})
}
}
rn.mu.Lock()
rn.status.CurrentIP = ip
rn.status.LastUpdated = time.Now()
rn.status.LastError = lastErr
rn.status.Records = records
rn.mu.Unlock()
}
// getPublicIP returns the current public IPv4 address.
// Tries Cloudflare's trace endpoint first, falls back to ipify.org.
func getPublicIP() (string, error) {
if ip, err := ipFromCloudflareTrace(); err == nil {
return ip, nil
}
return ipFromURL("https://api.ipify.org")
}
// ipFromCloudflareTrace parses the ip= field from https://cloudflare.com/cdn-cgi/trace
func ipFromCloudflareTrace() (string, error) {
resp, err := http.Get("https://cloudflare.com/cdn-cgi/trace")
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
for line := range strings.SplitSeq(string(body), "\n") {
if ip, ok := strings.CutPrefix(line, "ip="); ok {
ip = strings.TrimSpace(ip)
if ip != "" {
return ip, nil
}
}
}
return "", fmt.Errorf("ip not found in cloudflare trace response")
}
func ipFromURL(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("fetching public IP from %s: %w", url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response from %s: %w", url, err)
}
ip := strings.TrimSpace(string(body))
if ip == "" {
return "", fmt.Errorf("empty response from %s", url)
}
return ip, nil
}
// --- Cloudflare API client ---
// cfClient wraps the Cloudflare API token and provides DNS record operations.
type cfClient struct {
apiToken string
}
// cloudflareRecord is used to find the record ID for a given hostname
type cloudflareRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
}
// do executes an authenticated Cloudflare API request and checks the status.
func (c *cfClient) do(method, url string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
}
return resp, nil
}
// updateRecord updates a single A record via the Cloudflare API.
// If a CNAME already exists at that name, it is deleted first.
func (c *cfClient) updateRecord(recordName, ip string) error {
parts := strings.Split(recordName, ".")
if len(parts) < 2 {
return fmt.Errorf("invalid record name: %s", recordName)
}
zoneName := strings.Join(parts[len(parts)-2:], ".")
zoneID, err := c.getZoneID(zoneName)
if err != nil {
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
}
// Check for existing A record — patch it if found
recordID, currentIP, err := c.getRecordID(zoneID, recordName, "A")
if err == nil {
if currentIP == ip {
return nil // already up to date
}
return c.patchRecord(zoneID, recordID, recordName, ip)
}
// No A record. Remove any CNAME that would conflict.
if cnameID, _, cnameErr := c.getRecordID(zoneID, recordName, "CNAME"); cnameErr == nil {
slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName)
if err := c.deleteRecord(zoneID, cnameID); err != nil {
return fmt.Errorf("removing CNAME for %s: %w", recordName, err)
}
}
slog.Info("DDNS: creating A record", "component", "ddns", "record", recordName)
return c.createRecord(zoneID, recordName, ip)
}
func (c *cfClient) getZoneID(zoneName string) (string, error) {
resp, err := c.do(http.MethodGet, "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Result []struct {
ID string `json:"id"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if len(result.Result) == 0 {
return "", fmt.Errorf("zone %q not found", zoneName)
}
return result.Result[0].ID, nil
}
func (c *cfClient) getRecordID(zoneID, recordName, recordType string) (id, currentContent string, err error) {
resp, err := c.do(http.MethodGet,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, recordName), nil)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
var result struct {
Result []cloudflareRecord `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", err
}
if len(result.Result) == 0 {
return "", "", fmt.Errorf("%s record %q not found", recordType, recordName)
}
return result.Result[0].ID, result.Result[0].Content, nil
}
func (c *cfClient) createRecord(zoneID, recordName, ip string) error {
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
resp, err := c.do(http.MethodPost,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating record: %w", err)
}
resp.Body.Close()
return nil
}
func (c *cfClient) patchRecord(zoneID, recordID, recordName, ip string) error {
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
resp, err := c.do(http.MethodPatch,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("updating record: %w", err)
}
resp.Body.Close()
return nil
}
func (c *cfClient) deleteRecord(zoneID, recordID string) error {
resp, err := c.do(http.MethodDelete,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), nil)
if err != nil {
return fmt.Errorf("deleting record: %w", err)
}
resp.Body.Close()
return nil
}