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
|
||||
*config.yaml
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
152
README.md
152
README.md
@@ -1,3 +1,153 @@
|
||||
# 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