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 }