Files
havline-native/app-src/internal/ddns/service.go
T

710 lines
19 KiB
Go

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
}