chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,709 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/publicip"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/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
|
||||
}
|
||||
Reference in New Issue
Block a user