package main import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" "gopkg.in/yaml.v3" ) // Config represents the YAML configuration structure type Config struct { CheckInterval int `yaml:"check_interval"` Cloudflare struct { APIToken string `yaml:"api_token"` ZoneID string `yaml:"zone_id"` ZoneName string `yaml:"zone_name"` // <- add this Subdomains []struct { Name string `yaml:"name"` Proxied bool `yaml:"proxied"` } `yaml:"subdomains"` } `yaml:"cloudflare"` Logging struct { File string `yaml:"file"` Level string `yaml:"level"` } `yaml:"logging"` Retry struct { MaxAttempts int `yaml:"max_attempts"` DelaySeconds int `yaml:"delay_seconds"` } `yaml:"retry"` Webhook struct { Enabled bool `yaml:"enabled"` URL string `yaml:"url"` } `yaml:"webhook"` } // CloudflareDNSRecord represents a DNS record from Cloudflare API type CloudflareDNSRecord struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` Proxied bool `json:"proxied"` TTL int `json:"ttl"` } type CloudflareError struct { Code int `json:"code"` Message string `json:"message"` } type CloudflareResponse[T any] struct { Success bool `json:"success"` Errors []CloudflareError `json:"errors"` // ← was `string`, now `[]CloudflareError` Messages []string `json:"messages"` Result T `json:"result"` } // DNSUpdater manages the DDNS update process type DNSUpdater struct { config *Config logger *slog.Logger httpClient *http.Client lastIP string } func main() { configPath := flag.String("config", "config.yaml", "path to config file") initConfig := flag.Bool("init-config", false, "print example config to stdout") flag.Parse() if *initConfig { printConfigTemplate() return } // Load configuration config, err := loadConfig(*configPath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) os.Exit(1) } // Setup logger using Go 1.21+ slog package logger := setupLogger(config) logger.Info("Starting DDNS Updater", "version", "1.0.0", "go", "1.23") // Create updater instance updater := &DNSUpdater{ config: config, logger: logger, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } // Setup graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan logger.Info("Shutdown signal received, stopping...") cancel() }() // Run the update loop updater.run(ctx) logger.Info("DDNS Updater stopped gracefully") } func printConfigTemplate() { tmpl := `# Check interval in minutes (5, 15, or 30) check_interval: 5 cloudflare: api_token: "your-api-token-here" zone_id: "your-zone-id-here" zone_name: "example.com" subdomains: - name: "lc" proxied: false - name: "git" proxied: false - name: "mempool" proxied: false logging: file: "ddns-updater.log" level: "info" retry: max_attempts: 3 delay_seconds: 5 webhook: enabled: false url: "" ` fmt.Print(tmpl) } // loadConfig reads and parses the YAML configuration file func loadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file: %w", err) } var config Config if err := yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } // Validate configuration if config.Cloudflare.APIToken == "" { return nil, errors.New("cloudflare.api_token is required") } if config.Cloudflare.ZoneID == "" { return nil, errors.New("cloudflare.zone_id is required") } if config.CheckInterval <= 0 { return nil, errors.New("check_interval must be positive") } return &config, nil } // setupLogger creates a structured logger with file output func setupLogger(config *Config) *slog.Logger { // Parse log level var level slog.Level switch strings.ToLower(config.Logging.Level) { case "debug": level = slog.LevelDebug case "info": level = slog.LevelInfo case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } // Open log file logFile, err := os.OpenFile(config.Logging.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { fmt.Fprintf(os.Stderr, "Failed to open log file: %v\n", err) os.Exit(1) } // Create multi-writer for both file and stdout multiWriter := io.MultiWriter(os.Stdout, logFile) // Use Go 1.21+ slog with JSON handler for structured logging handler := slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{ Level: level, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { // Customize timestamp format if a.Key == slog.TimeKey { return slog.String("time", a.Value.Time().Format(time.RFC3339)) } return a }, }) return slog.New(handler) } // run is the main update loop func (u *DNSUpdater) run(ctx context.Context) { ticker := time.NewTicker(time.Duration(u.config.CheckInterval) * time.Minute) defer ticker.Stop() // Run immediately on startup if err := u.updateDNS(ctx); err != nil { u.logger.Error("Initial DNS update failed", "error", err) } for { select { case <-ctx.Done(): return case <-ticker.C: if err := u.updateDNS(ctx); err != nil { u.logger.Error("DNS update failed", "error", err) } } } } func (u *DNSUpdater) fqdn(subdomain string) string { if subdomain == "@" || subdomain == "" { return u.config.Cloudflare.ZoneName // root record } return subdomain + "." + u.config.Cloudflare.ZoneName } // updateDNS checks current IP and updates DNS records if needed func (u *DNSUpdater) updateDNS(ctx context.Context) error { // Get current public IP currentIP, err := u.getCurrentIP(ctx) if err != nil { return fmt.Errorf("getting current IP: %w", err) } u.logger.Info("Current public IP", "ip", currentIP) // Check if IP has changed if currentIP == u.lastIP { u.logger.Debug("IP unchanged, skipping update") return nil } u.logger.Info("IP changed, updating DNS records", "old_ip", u.lastIP, "new_ip", currentIP) // Update each subdomain for _, subdomain := range u.config.Cloudflare.Subdomains { if err := u.updateSubdomain(ctx, subdomain.Name, currentIP, subdomain.Proxied); err != nil { u.logger.Error("Failed to update subdomain", "subdomain", subdomain.Name, "error", err, ) continue } u.logger.Info("Successfully updated subdomain", "subdomain", subdomain.Name, "ip", currentIP, ) } // Update last known IP u.lastIP = currentIP // Send webhook notification if enabled if u.config.Webhook.Enabled { go u.sendWebhook(currentIP) } return nil } // getCurrentIP fetches the current public IPv4 address func (u *DNSUpdater) getCurrentIP(ctx context.Context) (string, error) { services := []string{ "https://api.ipify.org", "https://checkip.amazonaws.com", "https://icanhazip.com", } var lastErr error for _, service := range services { req, err := http.NewRequestWithContext(ctx, "GET", service, nil) if err != nil { lastErr = err continue } resp, err := u.httpClient.Do(req) if err != nil { lastErr = err u.logger.Debug("IP service failed", "service", service, "error", err) continue } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = err continue } if resp.StatusCode == http.StatusOK { ip := strings.TrimSpace(string(body)) if ip != "" { return ip, nil } } } return "", fmt.Errorf("all IP services failed: %w", lastErr) } // updateSubdomain updates a single DNS record with retry logic func (u *DNSUpdater) updateSubdomain(ctx context.Context, subdomain, ip string, proxied bool) error { var lastErr error for attempt := range u.config.Retry.MaxAttempts { err := u.updateSubdomainAttempt(ctx, subdomain, ip, proxied) if err == nil { return nil } lastErr = err u.logger.Warn("Update attempt failed", "subdomain", subdomain, "attempt", attempt+1, "max_attempts", u.config.Retry.MaxAttempts, "error", err, ) if attempt < u.config.Retry.MaxAttempts-1 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(time.Duration(u.config.Retry.DelaySeconds) * time.Second): } } } return fmt.Errorf("failed after %d attempts: %w", u.config.Retry.MaxAttempts, lastErr) } // updateSubdomainAttempt performs a single update attempt func (u *DNSUpdater) updateSubdomainAttempt(ctx context.Context, subdomain, ip string, proxied bool) error { // First, get the current DNS record recordID, currentIP, err := u.getDNSRecord(ctx, subdomain) if err != nil { return fmt.Errorf("getting DNS record: %w", err) } // Check if update is needed if currentIP == ip { u.logger.Debug("DNS record already up to date", "subdomain", subdomain, "ip", ip) return nil } // Update the DNS record url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", u.config.Cloudflare.ZoneID, recordID) name := u.fqdn(subdomain) payload := map[string]any{ "type": "A", "name": name, "content": ip, "proxied": proxied, "ttl": 300, } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling payload: %w", err) } req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewReader(jsonData)) if err != nil { return fmt.Errorf("creating request: %w", err) } req.Header.Set("Authorization", "Bearer "+u.config.Cloudflare.APIToken) req.Header.Set("Content-Type", "application/json") resp, err := u.httpClient.Do(req) if err != nil { return fmt.Errorf("executing request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("reading response: %w", err) } var cfResp CloudflareResponse[CloudflareDNSRecord] if err := json.Unmarshal(body, &cfResp); err != nil { return fmt.Errorf("parsing response: %w", err) } if !cfResp.Success { return fmt.Errorf("cloudflare API error: %v", cfResp.Errors) } return nil } // getDNSRecord retrieves the record ID and current IP for a subdomain func (u *DNSUpdater) getDNSRecord(ctx context.Context, subdomain string) (recordID, currentIP string, err error) { name := u.fqdn(subdomain) url := fmt.Sprintf( "https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=A&name=%s", u.config.Cloudflare.ZoneID, name, ) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", "", fmt.Errorf("creating request: %w", err) } req.Header.Set("Authorization", "Bearer "+u.config.Cloudflare.APIToken) req.Header.Set("Content-Type", "application/json") resp, err := u.httpClient.Do(req) if err != nil { return "", "", fmt.Errorf("executing request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", "", fmt.Errorf("reading response: %w", err) } var cfResp CloudflareResponse[[]CloudflareDNSRecord] if err := json.Unmarshal(body, &cfResp); err != nil { return "", "", fmt.Errorf("parsing response: %w", err) } if !cfResp.Success { return "", "", fmt.Errorf("cloudflare API error: %v", cfResp.Errors) } if len(cfResp.Result) == 0 { return "", "", fmt.Errorf("no DNS record found for %s", subdomain) } record := cfResp.Result[0] return record.ID, record.Content, nil } // sendWebhook sends a notification to the configured webhook (placeholder) func (u *DNSUpdater) sendWebhook(newIP string) { if u.config.Webhook.URL == "" { return } payload := map[string]string{ "event": "ip_changed", "new_ip": newIP, "time": time.Now().Format(time.RFC3339), } jsonData, _ := json.Marshal(payload) req, err := http.NewRequest("POST", u.config.Webhook.URL, bytes.NewReader(jsonData)) if err != nil { u.logger.Error("Failed to create webhook request", "error", err) return } req.Header.Set("Content-Type", "application/json") resp, err := u.httpClient.Do(req) if err != nil { u.logger.Error("Failed to send webhook", "error", err) return } defer resp.Body.Close() u.logger.Info("Webhook sent", "status", resp.StatusCode) }