Files
ddns-updater/main.go
2026-01-13 01:50:13 +08:00

452 lines
11 KiB
Go

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)
}