452 lines
11 KiB
Go
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)
|
|
}
|