Implement Cloudflare DDNS updater
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
152
README.md
@@ -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 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
|
||||||
|
|||||||
BIN
ddns-updater
Executable file
BIN
ddns-updater
Executable file
Binary file not shown.
15
ddns-updater.log
Normal file
15
ddns-updater.log
Normal 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
5
go.mod
Normal 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
4
go.sum
Normal 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
451
main.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user