package cron import ( "context" "database/sql" "fmt" "log/slog" "sync" "time" cf "goflare/internal/cloudflare" "goflare/internal/database/queries" "goflare/internal/ipfetcher" "github.com/robfig/cron/v3" ) const ( settingKeyCurrentIP = "current_ip" settingKeyCurrentIPUpdatedAt = "current_ip_updated_at" settingKeyCurrentIPSource = "current_ip_source" ) type DDNSUpdater struct { q *queries.Queries cron *cron.Cron entryID cron.EntryID schedule string mu sync.Mutex lastIP string } func NewDDNSUpdater(q *queries.Queries) *DDNSUpdater { return &DDNSUpdater{ q: q, cron: cron.New(), } } func (u *DDNSUpdater) Start(schedule string) error { id, err := u.cron.AddFunc(schedule, u.run) if err != nil { return fmt.Errorf("invalid cron schedule %q: %w", schedule, err) } u.entryID = id u.schedule = schedule u.cron.Start() ctx := context.Background() cached, err := u.q.GetSetting(ctx, settingKeyCurrentIP) if err == nil && cached != "" { u.mu.Lock() u.lastIP = cached u.mu.Unlock() } if err != nil && err != sql.ErrNoRows { slog.Warn("failed to load current_ip setting", "error", err) } slog.Info("ddns updater started", "schedule", schedule) return nil } func (u *DDNSUpdater) Stop() { u.cron.Stop() slog.Info("ddns updater stopped") } func (u *DDNSUpdater) Schedule() string { u.mu.Lock() defer u.mu.Unlock() return u.schedule } func (u *DDNSUpdater) Reschedule(schedule string) error { u.mu.Lock() defer u.mu.Unlock() id, err := u.cron.AddFunc(schedule, u.run) if err != nil { return fmt.Errorf("invalid cron schedule %q: %w", schedule, err) } u.cron.Remove(u.entryID) u.entryID = id u.schedule = schedule slog.Info("ddns updater rescheduled", "schedule", schedule) return nil } func (u *DDNSUpdater) run() { ctx := context.Background() urls, err := u.q.ListEnabledIpProviderUrls(ctx) if err != nil { slog.Error("failed to load IP providers", "error", err) return } var urlsOrNil []string if len(urls) > 0 { urlsOrNil = urls } res, err := ipfetcher.FetchPublicIPFrom(ctx, urlsOrNil) if err != nil { slog.Error("failed to fetch public IP", "error", err) return } ip := res.IP u.mu.Lock() lastIP := u.lastIP u.mu.Unlock() if ip == lastIP { slog.Debug("public IP unchanged", "ip", ip) return } slog.Info("public IP changed", "old", lastIP, "new", ip) records, err := u.q.ListNonStaticRecords(ctx) if err != nil { slog.Error("failed to list non-static records", "error", err) return } if len(records) == 0 { now := time.Now().UTC().Format(time.RFC3339) if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIP, Value: ip}); err != nil { slog.Error("failed to persist current_ip", "error", err) } else if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIPUpdatedAt, Value: now}); err != nil { slog.Error("failed to persist current_ip_updated_at", "error", err) } else if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIPSource, Value: res.Source}); err != nil { slog.Error("failed to persist current_ip_source", "error", err) } u.mu.Lock() u.lastIP = ip u.mu.Unlock() return } type zoneKey struct { apiKey string zoneID string } byZone := make(map[zoneKey][]queries.ListNonStaticRecordsRow) for _, rec := range records { k := zoneKey{apiKey: rec.ZoneApiKey, zoneID: rec.CfZoneID} byZone[k] = append(byZone[k], rec) } allOK := true for k, zoneRecs := range byZone { cfRecords := make([]cf.DNSRecord, len(zoneRecs)) for i, rec := range zoneRecs { cfRecords[i] = cf.DNSRecord{ ID: rec.CfRecordID, Type: rec.Type, Name: rec.Name, Content: ip, Proxied: rec.Proxied == 1, TTL: 1, // auto } } if err := cf.BatchUpdateDNSRecords(ctx, k.apiKey, k.zoneID, cfRecords); err != nil { slog.Error("failed to batch update DNS records", "zone", k.zoneID, "count", len(zoneRecs), "error", err, ) allOK = false continue } for _, rec := range zoneRecs { if err := u.q.UpdateRecordContent(ctx, queries.UpdateRecordContentParams{ ID: rec.ID, Content: ip, }); err != nil { slog.Error("failed to update local record content", "record", rec.Name, "error", err, ) allOK = false continue } slog.Info("updated DNS record", "record", rec.Name, "ip", ip) } } if allOK { now := time.Now().UTC().Format(time.RFC3339) if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIP, Value: ip}); err != nil { slog.Error("failed to persist current_ip", "error", err) } else if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIPUpdatedAt, Value: now}); err != nil { slog.Error("failed to persist current_ip_updated_at", "error", err) } else if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIPSource, Value: res.Source}); err != nil { slog.Error("failed to persist current_ip_source", "error", err) } u.mu.Lock() u.lastIP = ip u.mu.Unlock() } }