package ddns import ( "context" "fmt" "log/slog" "strings" "time" "github.com/havline/havline/internal/notify" "github.com/havline/havline/internal/publicip" "github.com/havline/havline/internal/secret" "github.com/havline/havline/internal/settings" ) type Service struct { store *Store settings *settings.Store secretBox *secret.Box providers map[string]Provider logger *slog.Logger notify *notify.Service } func NewService(store *Store, settings *settings.Store, secretBox *secret.Box, logger *slog.Logger, notifySvc *notify.Service) *Service { return &Service{ store: store, settings: settings, secretBox: secretBox, providers: map[string]Provider{ "cloudflare": NewCloudflare(), "dnspod": NewDNSPod(), "alidns": NewAliDNS(), "tencentcloud": NewTencentCloud(), "volcengine": NewVolcengine(), }, logger: logger.With("module", "DDNS"), notify: notifySvc, } } func (s *Service) List(ctx context.Context) ([]Config, error) { configs, err := s.store.List(ctx) if err != nil { return nil, err } for i := range configs { s.enrichDomainRecords(ctx, &configs[i]) sanitizeConfig(&configs[i]) } return configs, nil } func (s *Service) ListLite(ctx context.Context) ([]Config, error) { configs, err := s.store.List(ctx) if err != nil { return nil, err } for i := range configs { if len(configs[i].DomainRecords) == 0 { configs[i].DomainRecords = BuildDomainRecords(configs[i]) } sanitizeConfig(&configs[i]) } return configs, nil } func (s *Service) Get(ctx context.Context, id int64) (Config, error) { cfg, err := s.store.GetByID(ctx, id) if err != nil { return Config{}, err } s.enrichDomainRecords(ctx, &cfg) sanitizeConfig(&cfg) return cfg, nil } func (s *Service) CredentialsForDomain(ctx context.Context, rootDomain string) (string, Credentials, error) { cfg, err := s.store.GetByRootDomain(ctx, rootDomain) if err != nil { return "", Credentials{}, err } return s.credentialsForConfig(ctx, cfg) } func (s *Service) CredentialsForConfigID(ctx context.Context, id int64) (Config, string, Credentials, error) { cfg, err := s.Get(ctx, id) if err != nil { return Config{}, "", Credentials{}, fmt.Errorf("未找到所选 DNS 配置") } provider, cred, err := s.credentialsForConfig(ctx, cfg) if err != nil { return Config{}, "", Credentials{}, err } return cfg, provider, cred, nil } func (s *Service) ConfigForDNSZone(ctx context.Context, dnsZone string) (Config, error) { cfg, err := s.store.GetByRootDomain(ctx, dnsZone) if err != nil { return Config{}, err } if err := requireEnabled(cfg); err != nil { return Config{}, err } return cfg, nil } func (s *Service) ConfigForAnyDomain(ctx context.Context, domain string) (Config, error) { domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*."))) configs, err := s.store.List(ctx) if err != nil { return Config{}, err } for _, cfg := range configs { if !cfg.Enabled { continue } if cfg.CoversDomain(domain) { return cfg, nil } } return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", domain) } func (s *Service) CredentialsForAnyDomain(ctx context.Context, domain string) (string, Credentials, error) { cfg, err := s.ConfigForAnyDomain(ctx, domain) if err != nil { return "", Credentials{}, fmt.Errorf("请先在 DDNS 页面配置域名 %s 的 DNS 凭证", strings.TrimPrefix(domain, "*.")) } return s.credentialsForConfig(ctx, cfg) } func requireEnabled(cfg Config) error { if !cfg.Enabled { return fmt.Errorf("DNS 任务 %s 未启用,请先在 DDNS 页面开启", cfg.RootDomain) } return nil } func (s *Service) credentialsForConfig(ctx context.Context, cfg Config) (string, Credentials, error) { if err := requireEnabled(cfg); err != nil { return cfg.Provider, Credentials{}, err } cred, err := s.loadCredentialsByID(ctx, cfg.ID) if err != nil { return cfg.Provider, Credentials{}, err } cred.Provider = cfg.Provider return cfg.Provider, cred, nil } func (s *Service) Create(ctx context.Context, in SaveInput) (Config, error) { if err := validateSaveInput(in); err != nil { return Config{}, err } if !in.HasCredentialUpdate() { return Config{}, fmt.Errorf("请填写 DNS API 凭证") } tokenEnc, err := s.encryptCredentials(in) if err != nil { return Config{}, err } return s.store.Create(ctx, in, tokenEnc) } func (s *Service) Update(ctx context.Context, id int64, in SaveInput) (Config, error) { if err := validateSaveInput(in); err != nil { return Config{}, err } existing, err := s.store.GetByID(ctx, id) if err != nil { return Config{}, err } tokenEnc := "" updateToken := in.HasCredentialUpdate() if updateToken { tokenEnc, err = s.encryptCredentials(in) if err != nil { return Config{}, err } } else if !existing.HasToken { return Config{}, fmt.Errorf("请填写 DNS API 凭证") } return s.store.Update(ctx, id, in, tokenEnc, updateToken) } func (s *Service) Delete(ctx context.Context, id int64) error { return s.store.Delete(ctx, id) } type TestInput struct { ConfigID int64 Provider string APIToken string APITokenID string APISecret string } func (s *Service) Test(ctx context.Context, in TestInput) error { providerName := strings.TrimSpace(in.Provider) cred := CredentialsFromSave(SaveInput{ Provider: providerName, APIToken: in.APIToken, APITokenID: in.APITokenID, APISecret: in.APISecret, }) if cred.HasValues() { if providerName == "" { providerName = "cloudflare" } cred.Provider = providerName return s.providerFor(providerName).Verify(ctx, cred) } if in.ConfigID > 0 { cfg, err := s.store.GetByID(ctx, in.ConfigID) if err != nil { return err } cred, err = s.loadCredentialsByID(ctx, in.ConfigID) if err != nil { return err } cred.Provider = cfg.Provider return s.providerFor(cfg.Provider).Verify(ctx, cred) } return fmt.Errorf("请填写 DNS API 凭证") } func (s *Service) UpdateNow(ctx context.Context, id int64) (Config, error) { cfg, err := s.store.GetByID(ctx, id) if err != nil { return Config{}, err } if !cfg.Enabled { return Config{}, fmt.Errorf("该 DDNS 配置未启用") } if err := s.runUpdate(ctx, cfg); err != nil { return Config{}, err } return s.Get(ctx, id) } func (s *Service) UpdateAll(ctx context.Context) ([]Config, error) { configs, err := s.store.List(ctx) if err != nil { return nil, err } var lastErr error var failCount int var runCount int for _, cfg := range configs { if !cfg.Enabled { continue } runCount++ if err := s.runUpdate(ctx, cfg); err != nil { lastErr = err failCount++ s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error()) } } list, listErr := s.List(ctx) if listErr != nil { return nil, listErr } if failCount > 0 && failCount == runCount { return list, lastErr } return list, nil } func (s *Service) Tick(ctx context.Context) { configs, err := s.store.List(ctx) if err != nil { return } for _, cfg := range configs { if !cfg.Enabled { continue } if err := s.runUpdate(ctx, cfg); err != nil { s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error()) if s.notify != nil { s.notify.Alert(ctx, notify.EventDDNSFailure, "DDNS 更新失败", cfg.RootDomain+": "+err.Error()) } } } } // PublicIPs returns the most recently synced public IPs from enabled DDNS configs. func (s *Service) PublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) { return s.latestStoredPublicIPs(ctx, true) } // LastKnownPublicIPs returns stored public IPs from the most recently updated config, // including paused tasks. Used to avoid live IP detection when DDNS is temporarily disabled. func (s *Service) LastKnownPublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) { return s.latestStoredPublicIPs(ctx, false) } func (s *Service) latestStoredPublicIPs(ctx context.Context, enabledOnly bool) (ipv4, ipv6 string, ok bool) { configs, err := s.store.List(ctx) if err != nil || len(configs) == 0 { return "", "", false } var latest *Config for _, cfg := range configs { if enabledOnly && !cfg.Enabled { continue } if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) { latest = &cfg } } if latest == nil { return "", "", false } if latest.LastIPv4 == "" && latest.LastIPv6 == "" { return "", "", false } return latest.LastIPv4, latest.LastIPv6, true } func (s *Service) Summary(ctx context.Context) (status string, lastUpdated string, count int) { configs, err := s.store.List(ctx) if err != nil || len(configs) == 0 { return "disabled", "", 0 } count = len(configs) status = "ok" hasEnabled := false var latest *Config for _, cfg := range configs { if cfg.Enabled { hasEnabled = true } if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) { latest = &cfg } if cfg.LastStatus == "error" { status = "error" } } if !hasEnabled { return "disabled", "", count } if latest != nil && latest.LastUpdatedAt != nil { lastUpdated = latest.LastUpdatedAt.UTC().Format(time.RFC3339) } if status != "error" && latest != nil && latest.LastStatus != "" { status = latest.LastStatus } return status, lastUpdated, count } func sanitizeConfig(cfg *Config) { if !publicip.IsPublicIPv4(cfg.LastIPv4) { cfg.LastIPv4 = "" } if !publicip.IsPublicIPv6(cfg.LastIPv6) { cfg.LastIPv6 = "" } } func (s *Service) enrichDomainRecords(ctx context.Context, cfg *Config) { records := BuildDomainRecords(*cfg) if !cfg.HasToken || len(records) == 0 { cfg.DomainRecords = records return } cred, err := s.loadCredentialsByID(ctx, cfg.ID) if err != nil { cfg.DomainRecords = records return } cred.Provider = cfg.Provider provider := s.providerFor(cfg.Provider) zoneCache := make(map[string]zoneHost) for i := range records { prevIPv4 := records[i].IPv4 prevIPv6 := records[i].IPv6 prevStatus := records[i].Status prevMessage := records[i].Message fqdn := FQDNFromRecord(*cfg, records[i].Domain) zone, record, err := s.resolveZone(ctx, provider, cred, fqdn, zoneCache) if err != nil { records[i].Domain = fqdn records[i].Status = "error" records[i].Message = err.Error() continue } records[i].Domain = FormatDomainLine(zone, record) if cfg.IPv4Enabled { ip, err := provider.GetRecordIP(ctx, cred, zone, record, "A") switch { case err != nil: records[i].IPv4 = prevIPv4 if prevStatus == "" || prevStatus == "unknown" { records[i].Status = "error" records[i].Message = err.Error() } case ip != "": records[i].IPv4 = ip if prevStatus == "" || prevStatus == "unknown" { records[i].Status = "unchanged" records[i].Message = "记录未变化" } else { records[i].Status = prevStatus records[i].Message = prevMessage } default: records[i].IPv4 = prevIPv4 } } if cfg.IPv6Enabled { ip, err := provider.GetRecordIP(ctx, cred, zone, record, "AAAA") switch { case err != nil: records[i].IPv6 = prevIPv6 if records[i].Status == "" || records[i].Status == "unknown" { records[i].Status = "error" records[i].Message = err.Error() } case ip != "": records[i].IPv6 = ip default: records[i].IPv6 = prevIPv6 } } if records[i].Status == "" { records[i].Status = "unknown" } } cfg.DomainRecords = records } func (s *Service) runUpdate(ctx context.Context, cfg Config) error { prevIPv4 := cfg.LastIPv4 prevIPv6 := cfg.LastIPv6 cred, err := s.loadCredentialsByID(ctx, cfg.ID) if err != nil { _ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", err.Error()) return err } cred.Provider = cfg.Provider provider := s.providerFor(cfg.Provider) detectedIPv4, detectedIPv6, _ := publicip.Detect(ctx) if detectedIPv4 != "" && !publicip.IsPublicIPv4(detectedIPv4) { detectedIPv4 = "" } if detectedIPv6 != "" && !publicip.IsPublicIPv6(detectedIPv6) { detectedIPv6 = "" } // 自定义 IP 优先:填写后直接作为更新目标(如公网服务器 IP),不再依赖本机出口 IP 检测 customIPv4 := strings.TrimSpace(cfg.CustomIPv4) customIPv6 := strings.TrimSpace(cfg.CustomIPv6) if customIPv4 != "" && !publicip.IsPublicIPv4(customIPv4) { msg := "自定义 IPv4 不是公网地址,已拒绝更新" _ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg) return fmt.Errorf(msg) } if customIPv6 != "" && !publicip.IsPublicIPv6(customIPv6) { msg := "自定义 IPv6 不是公网地址,已拒绝更新" _ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg) return fmt.Errorf(msg) } if customIPv4 != "" { detectedIPv4 = customIPv4 } if customIPv6 != "" { detectedIPv6 = customIPv6 } storeIPv4 := cfg.LastIPv4 storeIPv6 := cfg.LastIPv6 if !publicip.IsPublicIPv4(storeIPv4) { storeIPv4 = "" } if !publicip.IsPublicIPv6(storeIPv6) { storeIPv6 = "" } if cfg.IPv4Enabled && detectedIPv4 != "" { storeIPv4 = detectedIPv4 } if cfg.IPv6Enabled && detectedIPv6 != "" { storeIPv6 = detectedIPv6 } if cfg.IPv4Enabled && detectedIPv4 == "" { msg := "无法获取公网 IPv4,已跳过更新(不会写入内网 IP)" _ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg) return fmt.Errorf(msg) } if cfg.IPv6Enabled && detectedIPv6 == "" { msg := "无法获取公网 IPv6,已跳过更新" _ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg) return fmt.Errorf(msg) } recordNames := cfg.RecordNamesList() domainRecords := make([]DomainRecord, 0, len(recordNames)) var lastErr error var errCount, okCount int changed := false zoneCache := make(map[string]zoneHost) for _, name := range recordNames { fqdn := FQDNFromRecord(cfg, name) zone, record, zoneErr := s.resolveZone(ctx, provider, cred, fqdn, zoneCache) if zoneErr != nil { dr := DomainRecord{Domain: fqdn, Status: "error", Message: zoneErr.Error()} domainRecords = append(domainRecords, dr) lastErr = zoneErr errCount++ continue } fqdn = FormatDomainLine(zone, record) dr := DomainRecord{Domain: fqdn} if cfg.IPv4Enabled && detectedIPv4 != "" { current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "A") if getErr != nil { dr.Status = "error" dr.Message = getErr.Error() lastErr = getErr domainRecords = append(domainRecords, dr) continue } dr.IPv4 = current if current == detectedIPv4 { dr.Status = "unchanged" dr.Message = "记录未变化" } else if !publicip.IsPublicIPv4(detectedIPv4) { dr.Status = "error" dr.Message = "检测到内网 IPv4,拒绝更新" lastErr = fmt.Errorf("检测到内网 IPv4,拒绝更新") } else if err := provider.UpdateRecord(ctx, cred, zone, record, "A", detectedIPv4); err != nil { dr.Status = "error" dr.Message = err.Error() lastErr = err } else { dr.IPv4 = detectedIPv4 dr.Status = "ok" dr.Message = "已更新" changed = true s.logger.Info("ddns ipv4 updated", "domain", fqdn, "ip", detectedIPv4) } } if cfg.IPv6Enabled && detectedIPv6 != "" && dr.Status != "error" { current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "AAAA") if getErr != nil { dr.Status = "error" dr.Message = getErr.Error() lastErr = getErr } else { dr.IPv6 = current if current == detectedIPv6 { if dr.Status == "" { dr.Status = "unchanged" dr.Message = "记录未变化" } } else if !publicip.IsPublicIPv6(detectedIPv6) { dr.Status = "error" dr.Message = "检测到内网 IPv6,拒绝更新" lastErr = fmt.Errorf("检测到内网 IPv6,拒绝更新") } else if err := provider.UpdateRecord(ctx, cred, zone, record, "AAAA", detectedIPv6); err != nil { dr.Status = "error" dr.Message = err.Error() lastErr = err } else { dr.IPv6 = detectedIPv6 dr.Status = "ok" dr.Message = "已更新" changed = true s.logger.Info("ddns ipv6 updated", "domain", fqdn, "ip", detectedIPv6) } } } if dr.Status == "" { dr.Status = "unchanged" dr.Message = "记录未变化" } if dr.Status == "error" { errCount++ } else { okCount++ } domainRecords = append(domainRecords, dr) } overallStatus := "ok" overallError := "" switch { case errCount > 0 && okCount == 0: overallStatus = "error" if lastErr != nil { overallError = lastErr.Error() } case errCount > 0: overallStatus = "warning" overallError = fmt.Sprintf("%d 个域名同步失败,其余正常", errCount) lastErr = nil } if changed { s.logger.Info("ddns records updated", "provider", cfg.Provider) if s.notify != nil { msg := formatDDNSIPChangeMessage(cfg.RootDomain, prevIPv4, prevIPv6, storeIPv4, storeIPv6) s.notify.Alert(ctx, notify.EventDDNSIPChange, "DDNS IP 已变更", msg) } } _ = s.store.UpdateSyncResult(ctx, cfg.ID, storeIPv4, storeIPv6, overallStatus, overallError, domainRecords) return lastErr } func formatDDNSIPChangeMessage(domain, prevIPv4, prevIPv6, newIPv4, newIPv6 string) string { var parts []string if newIPv4 != "" && newIPv4 != prevIPv4 { if prevIPv4 != "" { parts = append(parts, fmt.Sprintf("IPv4: %s → %s", prevIPv4, newIPv4)) } else { parts = append(parts, fmt.Sprintf("IPv4: %s", newIPv4)) } } if newIPv6 != "" && newIPv6 != prevIPv6 { if prevIPv6 != "" { parts = append(parts, fmt.Sprintf("IPv6: %s → %s", prevIPv6, newIPv6)) } else { parts = append(parts, fmt.Sprintf("IPv6: %s", newIPv6)) } } if len(parts) == 0 { return domain } return domain + "\n" + strings.Join(parts, "\n") } func (s *Service) encryptCredentials(in SaveInput) (string, error) { cred := CredentialsFromSave(in) if err := cred.Validate(in.Provider); err != nil { return "", err } raw, err := cred.Marshal() if err != nil { return "", err } return s.secretBox.Encrypt(string(raw)) } func (s *Service) loadCredentialsByID(ctx context.Context, id int64) (Credentials, error) { enc, err := s.store.GetTokenByID(ctx, id) if err != nil || enc == "" { return Credentials{}, fmt.Errorf("DNS API 凭证未配置") } raw, err := s.secretBox.Decrypt(enc) if err != nil { return Credentials{}, secret.DecryptHint(err) } return ParseCredentials(raw) } func (s *Service) providerFor(name string) Provider { if p, ok := s.providers[name]; ok { return p } return s.providers["cloudflare"] } func (s *Service) resolveZone(ctx context.Context, provider Provider, cred Credentials, fqdn string, cache map[string]zoneHost) (zone, host string, err error) { if cache != nil { if item, ok := cache[fqdn]; ok { return item.zone, item.host, item.err } } zone, host, err = ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) { return provider.HasZone(ctx, cred, candidate) }, fqdn) if cache != nil { cache[fqdn] = zoneHost{zone: zone, host: host, err: err} } return zone, host, err } type zoneHost struct { zone string host string err error } func validateSaveInput(in SaveInput) error { if _, err := FQDNsFromSaveInput(in); err != nil { return err } switch strings.TrimSpace(in.Provider) { case "dnspod", "cloudflare", "alidns", "tencentcloud", "volcengine", "": default: return fmt.Errorf("不支持的 DNS Provider") } return nil }