Implement Cloudflare DDNS updater

This commit is contained in:
LC mac
2026-01-13 01:50:13 +08:00
parent da61fa7093
commit ce5a0a87ba
7 changed files with 627 additions and 1 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
*config.yaml
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out

152
README.md
View File

@@ -1,3 +1,153 @@
# ddns-updater # ddns-updater
Cloudfare DDNS updater 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 systemds 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

BIN
ddns-updater Executable file

Binary file not shown.

15
ddns-updater.log Normal file
View File

@@ -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"}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module git.yuksing.com/kccleoc/ddns-updater
go 1.25.5
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -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=

451
main.go Normal file
View File

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