diff --git a/.gitignore b/.gitignore index 5b90e79..87465b6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # Test binary, built with `go test -c` *.test +*config.yaml # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/README.md b/README.md index 7eb2e65..d5ee6b3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,153 @@ # ddns-updater -Cloudfare DDNS updater \ No newline at end of file +A small Go daemon that keeps Cloudflare A records in sync with your current public IPv4 address. +It reads a YAML config, checks your IP periodically, compares it to existing DNS records, and only updates when needed. + +## Features + +- Uses Cloudflare API token (modern auth) +- Supports multiple subdomains in one zone +- IPv4 only (A records) +- User-defined check interval (minutes) +- Retries on failure, then logs and skips +- Structured JSON logging to file + +## Requirements + +- Go 1.21+ (tested with 1.23) +- A Cloudflare account +- API token with: + - Zone:DNS:Edit + - Zone:Zone:Read +- Existing zone and A records for your subdomains + +## Installation + +```bash +# 1. Create project directory +mkdir ddns-updater +cd ddns-updater + +# 2. Initialize module +go mod init github.com/yourname/ddns-updater + +# 3. Add files +# - main.go +# - config.yaml +# - (this README.md) + +# 4. Fetch dependencies +go mod tidy + +# 5. Build binary (current platform) +go build -o ddns-updater + +# 6. Build for Linux (LXC, etc.) +GOOS=linux GOARCH=amd64 go build -o ddns-updater-linux +``` + +Copy the resulting binary and `config.yaml` onto your target machine or LXC container, e.g. `/opt/ddns-updater`. + +## Configuration + +Create `config.yaml` in the same directory as the binary: + +```yaml +check_interval: 5 # minutes (e.g. 5, 15, 30) + +cloudflare: + api_token: "YOUR_CF_API_TOKEN" + zone_id: "YOUR_ZONE_ID" + subdomains: + - name: "lc" + proxied: false + - name: "git" + proxied: false + - name: "mempool" + proxied: false + +logging: + file: "ddns-updater.log" + level: "info" # debug, info, warn, error + +retry: + max_attempts: 3 + delay_seconds: 5 + +webhook: + enabled: false + url: "" +``` + +- `check_interval`: how often to check your current IP (in minutes). +- `subdomains`: just the left part of the name (e.g. `git` for `git.example.com`). +- `proxied`: `true` for orange-cloud (proxied), `false` for DNS only. +- `logging.file`: log file path, relative to the working directory. +- `retry`: per-subdomain retry behavior when the Cloudflare API or network fails temporarily. + +## Usage + +### Direct run + +From the directory containing the binary and `config.yaml`: + +```bash +./ddns-updater +``` + +The process will: + +1. Load `config.yaml`. +2. Detect current public IPv4 using external services. +3. For each subdomain: + - Fetch its current A record from Cloudflare. + - If the IP differs, update the record. +4. Sleep for `check_interval` minutes and repeat. +5. Log events to `ddns-updater.log`. + +### Run in background (simple) + +```bash +nohup ./ddns-updater >/dev/null 2>&1 & +``` + +### Example systemd service (on Linux/LXC) + +Create `/etc/systemd/system/ddns-updater.service`: + +```ini +[Unit] +Description=Cloudflare DDNS Updater +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/ddns-updater +ExecStart=/opt/ddns-updater/ddns-updater +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Then: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable ddns-updater +sudo systemctl start ddns-updater +sudo systemctl status ddns-updater +``` + +Logs will go to `ddns-updater.log` in `/opt/ddns-updater` plus systemd’s journal. + +## Updating + +To update the binary: + +```bash +git pull # if you put it in a repo +go build -o ddns-updater +sudo systemctl restart ddns-updater diff --git a/ddns-updater b/ddns-updater new file mode 100755 index 0000000..76492d1 Binary files /dev/null and b/ddns-updater differ diff --git a/ddns-updater.log b/ddns-updater.log new file mode 100644 index 0000000..3221295 --- /dev/null +++ b/ddns-updater.log @@ -0,0 +1,15 @@ +{"time":"2026-01-13T01:47:33+08:00","level":"INFO","msg":"Starting DDNS Updater","version":"1.0.0","go":"1.23"} +{"time":"2026-01-13T01:47:33+08:00","level":"INFO","msg":"Current public IP","ip":"218.102.220.216"} +{"time":"2026-01-13T01:47:33+08:00","level":"INFO","msg":"IP changed, updating DNS records","old_ip":"","new_ip":"218.102.220.216"} +{"time":"2026-01-13T01:47:33+08:00","level":"WARN","msg":"Update attempt failed","subdomain":"lc","attempt":1,"max_attempts":3,"error":"getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:38+08:00","level":"WARN","msg":"Update attempt failed","subdomain":"lc","attempt":2,"max_attempts":3,"error":"getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:43+08:00","level":"WARN","msg":"Update attempt failed","subdomain":"lc","attempt":3,"max_attempts":3,"error":"getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:43+08:00","level":"ERROR","msg":"Failed to update subdomain","subdomain":"lc","error":"failed after 3 attempts: getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:43+08:00","level":"WARN","msg":"Update attempt failed","subdomain":"git","attempt":1,"max_attempts":3,"error":"getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:48+08:00","level":"WARN","msg":"Update attempt failed","subdomain":"git","attempt":2,"max_attempts":3,"error":"getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:53+08:00","level":"WARN","msg":"Update attempt failed","subdomain":"git","attempt":3,"max_attempts":3,"error":"getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:53+08:00","level":"ERROR","msg":"Failed to update subdomain","subdomain":"git","error":"failed after 3 attempts: getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:53+08:00","level":"WARN","msg":"Update attempt failed","subdomain":"mempool","attempt":1,"max_attempts":3,"error":"getting DNS record: parsing response: json: cannot unmarshal object into Go struct field CloudflareResponse[[]main.CloudflareDNSRecord].errors of type string"} +{"time":"2026-01-13T01:47:57+08:00","level":"INFO","msg":"Shutdown signal received, stopping..."} +{"time":"2026-01-13T01:47:57+08:00","level":"ERROR","msg":"Failed to update subdomain","subdomain":"mempool","error":"context canceled"} +{"time":"2026-01-13T01:47:57+08:00","level":"INFO","msg":"DDNS Updater stopped gracefully"} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..94ee219 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.yuksing.com/kccleoc/ddns-updater + +go 1.25.5 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..57c22a5 --- /dev/null +++ b/main.go @@ -0,0 +1,451 @@ +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"` + 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"` +} + +// CloudflareResponse is the generic API response structure +type CloudflareResponse[T any] struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + 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") + flag.Parse() + + // 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") +} + +// 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) + } + } + } +} + +// 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) + + payload := map[string]any{ + "type": "A", + "name": subdomain, + "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) { + url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=A&name=%s", + u.config.Cloudflare.ZoneID, subdomain) + + 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) +}