package cron import ( "context" "database/sql" "fmt" "log/slog" "sync" cf "goflare/internal/cloudflare" "goflare/internal/database/queries" "goflare/internal/ipfetcher" "github.com/robfig/cron/v3" ) const settingKeyCurrentIP = "current_ip" 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 } ip, err := ipfetcher.FetchPublicIPFrom(ctx, urlsOrNil) if err != nil { slog.Error("failed to fetch public IP", "error", err) return } 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 { if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIP, Value: ip}); err != nil { slog.Error("failed to persist current_ip", "error", err) } u.mu.Lock() u.lastIP = ip u.mu.Unlock() return } allOK := true for _, rec := range records { err := cf.UpdateDNSRecord(ctx, rec.ZoneApiKey, rec.CfZoneID, rec.CfRecordID, cf.DNSRecord{ Type: rec.Type, Name: rec.Name, Content: ip, Proxied: rec.Proxied == 1, TTL: 1, // auto }) if err != nil { slog.Error("failed to update DNS record", "record", rec.Name, "zone", rec.CfZoneID, "error", err, ) allOK = false continue } err = u.q.UpdateRecordContent(ctx, queries.UpdateRecordContentParams{ ID: rec.ID, Content: ip, }) if 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 { if err := u.q.UpsertSetting(ctx, queries.UpsertSettingParams{Key: settingKeyCurrentIP, Value: ip}); err != nil { slog.Error("failed to persist current_ip", "error", err) } u.mu.Lock() u.lastIP = ip u.mu.Unlock() } }