package ddns import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "sync" "time" ) // Config holds the DDNS configuration type Config struct { Enabled bool APIToken string Records []string IntervalMinutes int } // 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 type Runner struct { mu sync.RWMutex status Status trigger chan struct{} cancel context.CancelFunc 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 or restarts the DDNS goroutine with the given config. // Safe to call multiple times — cancels the previous run first. // Enabled is set to true here (not inside Run) to eliminate the race where // the old goroutine's exit could clear Enabled after the new goroutine starts. func (rn *Runner) Start(parentCtx context.Context, cfg Config) { rn.mu.Lock() if rn.cancel != nil { rn.cancel() rn.cancel = nil } if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 { rn.status.Enabled = false rn.mu.Unlock() return } ctx, cancel := context.WithCancel(parentCtx) rn.cancel = cancel rn.status.Enabled = true rn.status.CurrentIP = "" // clear so first check always syncs all records rn.mu.Unlock() go rn.Run(ctx, cfg) } // 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 IP check and update (non-blocking) func (rn *Runner) Trigger() { select { case rn.trigger <- struct{}{}: default: } } // Run starts the DDNS polling loop. It exits when ctx is cancelled. // Enabled state is managed by Start/Stop, not this function. func (rn *Runner) Run(ctx context.Context, cfg Config) { if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 { slog.Info("DDNS disabled or not configured", "component", "ddns") return } interval := time.Duration(cfg.IntervalMinutes) * time.Minute if interval <= 0 { interval = 5 * time.Minute } slog.Info("DDNS started", "component", "ddns", "records", cfg.Records, "interval", interval) // Check immediately on start rn.checkAndUpdate(cfg) ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): slog.Info("DDNS stopped", "component", "ddns") return case <-ticker.C: rn.checkAndUpdate(cfg) case <-rn.trigger: rn.checkAndUpdate(cfg) } } } // checkAndUpdate fetches the current public IP and reconciles all records. // Records are always verified against Cloudflare, not just when the IP changes, // so external drift (e.g. a router DDNS overwriting with a CNAME) is self-healed. func (rn *Runner) checkAndUpdate(cfg Config) { rn.mu.Lock() rn.status.LastChecked = time.Now() rn.mu.Unlock() 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 } rn.mu.RLock() ipChanged := rn.status.CurrentIP != ip rn.mu.RUnlock() if !ipChanged { slog.Debug("DDNS: IP unchanged, skipping update", "component", "ddns", "ip", ip) return } slog.Info("DDNS: IP changed, updating records", "component", "ddns", "newIP", ip, "records", cfg.Records) var lastErr string records := make([]RecordStatus, 0, len(cfg.Records)) for _, record := range cfg.Records { if err := updateCloudflareRecord(cfg.APIToken, 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 } // 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"` } // updateCloudflareRecord updates a single A record via the Cloudflare API. // The record name must be a fully qualified domain name (e.g. "cloud.payne.io"). // If a CNAME already exists at that name (e.g. from a router DDNS service), it is // deleted first so the A record can be created without conflict. func updateCloudflareRecord(apiToken, recordName, ip string) error { // Extract zone name: last two parts of the FQDN (e.g. "payne.io" from "cloud.payne.io") parts := strings.Split(recordName, ".") if len(parts) < 2 { return fmt.Errorf("invalid record name: %s", recordName) } zoneName := strings.Join(parts[len(parts)-2:], ".") // Get zone ID zoneID, err := getCloudflareZoneID(apiToken, 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 := getCloudflareRecordID(apiToken, zoneID, recordName, "A") if err == nil { if currentIP == ip { return nil // already up to date } return patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip) } // No A record. Remove any CNAME that would conflict with A record creation // (common when migrating from a router-based DDNS service like GL.iNet). if cnameID, _, cnameErr := getCloudflareRecordID(apiToken, zoneID, recordName, "CNAME"); cnameErr == nil { slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName) if err := deleteCloudflareRecord(apiToken, 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 createCloudflareRecord(apiToken, zoneID, recordName, ip) } // createCloudflareRecord creates a new A record via the Cloudflare API func createCloudflareRecord(apiToken, zoneID, recordName, ip string) error { body, _ := json.Marshal(map[string]string{ "type": "A", "name": recordName, "content": ip, }) req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+apiToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("creating record: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) } return nil } // patchCloudflareRecord updates an existing A record via the Cloudflare API func patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip string) error { body, _ := json.Marshal(map[string]string{ "type": "A", "name": recordName, "content": ip, }) req, _ := http.NewRequest(http.MethodPatch, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+apiToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("updating record: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) } return nil } func getCloudflareZoneID(apiToken, zoneName string) (string, error) { req, _ := http.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) 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 getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, currentContent string, err error) { req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, recordName), nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) 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 deleteCloudflareRecord(apiToken, zoneID, recordID string) error { req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("deleting record: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) } return nil }