Files
cloudflare-dns-updater/main.go
kokopi-dev 98b8e20265 init
2026-03-08 02:16:42 +09:00

259 lines
6.1 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/joho/godotenv"
)
// --- Config ---
const (
lastIPFile = "/tmp/last_known_ip"
envFile = ".config/.cloudflare-dns-updater.env"
)
var apiToken string
// ZoneConfig maps a Cloudflare Zone ID to the domains within it.
type ZoneConfig struct {
ZoneID string
Domains []DomainRecord
}
type DomainRecord struct {
Name string
RecordType string
}
// buildZones reads zone and domain config from numbered env variables, e.g.:
// CLOUDFLARE_ZONE_1_ID=abc123
// CLOUDFLARE_ZONE_1_DOMAINS=api.example.com:A,example.com:A
// CLOUDFLARE_ZONE_2_ID=def456
// CLOUDFLARE_ZONE_2_DOMAINS=api.example.com:A
func buildZones() []ZoneConfig {
var zones []ZoneConfig
for i := 1; ; i++ {
zoneID := os.Getenv(fmt.Sprintf("CLOUDFLARE_ZONE_%d_ID", i))
if zoneID == "" {
break
}
domainsRaw := os.Getenv(fmt.Sprintf("CLOUDFLARE_ZONE_%d_DOMAINS", i))
if domainsRaw == "" {
log.Printf("Warning: zone %d has an ID but no DOMAINS set, skipping", i)
continue
}
var records []DomainRecord
for entry := range strings.SplitSeq(domainsRaw, ",") {
entry = strings.TrimSpace(entry)
parts := strings.SplitN(entry, ":", 2)
if len(parts) != 2 {
log.Printf("Warning: skipping malformed domain entry %q (expected name:TYPE)", entry)
continue
}
records = append(records, DomainRecord{
Name: strings.TrimSpace(parts[0]),
RecordType: strings.TrimSpace(parts[1]),
})
}
zones = append(zones, ZoneConfig{ZoneID: zoneID, Domains: records})
}
return zones
}
// --- Cloudflare API types ---
type DNSListResponse struct {
Success bool `json:"success"`
Result []DNSRecord `json:"result"`
}
type DNSRecord struct {
ID string `json:"id"`
Content string `json:"content"`
}
type DNSUpdatePayload struct {
Content string `json:"content"`
Type string `json:"type"`
Name string `json:"name"`
Proxied bool `json:"proxied"`
}
type DNSUpdateResponse struct {
Success bool `json:"success"`
}
// --- Helpers ---
func timestamp() string {
return time.Now().Format("2006-01-02 15:04:05")
}
func getCurrentIP() (string, error) {
out, err := exec.Command("dig", "+short", "myip.opendns.com", "@resolver1.opendns.com").Output()
if err != nil {
return "", err
}
ip := strings.TrimSpace(string(out))
matched, _ := regexp.MatchString(`^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`, ip)
if !matched {
return "", fmt.Errorf("invalid IP received: %s", ip)
}
return ip, nil
}
func readLastIP() string {
data, err := os.ReadFile(lastIPFile)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func writeLastIP(ip string) {
_ = os.WriteFile(lastIPFile, []byte(ip), 0644)
}
func cfRequest(method, url string, body any) ([]byte, error) {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func updateDomain(zoneID, currentIP string, domain DomainRecord) {
// Fetch existing DNS record
url := fmt.Sprintf(
"https://api.cloudflare.com/client/v4/zones/%s/dns_records?name=%s&type=%s",
zoneID, domain.Name, domain.RecordType,
)
data, err := cfRequest("GET", url, nil)
if err != nil {
log.Printf("Error fetching DNS record for %s: %v", domain.Name, err)
return
}
var listResp DNSListResponse
if err := json.Unmarshal(data, &listResp); err != nil || !listResp.Success {
log.Printf("Error parsing DNS response for %s: %s", domain.Name, string(data))
return
}
if len(listResp.Result) == 0 {
log.Printf("No DNS record found for %s", domain.Name)
return
}
record := listResp.Result[0]
if currentIP == record.Content {
fmt.Printf("[%s] %s IP unchanged: %s\n", timestamp(), domain.Name, currentIP)
writeLastIP(currentIP)
return
}
fmt.Printf("IP changed for %s: %s → %s. Updating...\n", domain.Name, record.Content, currentIP)
patchURL := fmt.Sprintf(
"https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s",
zoneID, record.ID,
)
payload := DNSUpdatePayload{
Content: currentIP,
Type: domain.RecordType,
Name: domain.Name,
Proxied: true,
}
resp, err := cfRequest("PATCH", patchURL, payload)
if err != nil {
log.Printf("Error updating DNS record for %s: %v", domain.Name, err)
return
}
var updateResp DNSUpdateResponse
if err := json.Unmarshal(resp, &updateResp); err != nil || !updateResp.Success {
log.Printf("Failed to update %s: %s", domain.Name, string(resp))
return
}
fmt.Printf("Successfully updated %s to %s\n", domain.Name, currentIP)
writeLastIP(currentIP)
}
func main() {
// Load env file from ~/.config/.cloudflare-dns-updater.env
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("Could not determine home directory: %v", err)
}
envPath := filepath.Join(home, envFile)
if err := godotenv.Load(envPath); err != nil {
log.Fatalf("Could not load env file %s: %v", envPath, err)
}
apiToken = os.Getenv("CLOUDFLARE_API_TOKEN")
if apiToken == "" {
log.Fatalf("CLOUDFLARE_API_TOKEN is not set in %s", envPath)
}
zones := buildZones()
if len(zones) == 0 {
log.Fatalf("No zones found — set CLOUDFLARE_ZONE_1_ID and CLOUDFLARE_ZONE_1_DOMAINS in %s", envPath)
}
currentIP, err := getCurrentIP()
if err != nil {
log.Fatalf("Failed to get current IP: %v", err)
}
lastIP := readLastIP()
if currentIP == lastIP {
fmt.Printf("[%s] IP unchanged (%s), skipping Cloudflare update\n", timestamp(), currentIP)
os.Exit(0)
}
fmt.Printf("Current IP: %s (was: %s)\n", currentIP, lastIP)
for _, zone := range zones {
if zone.ZoneID == "" {
log.Printf("Skipping zone with empty Zone ID")
continue
}
fmt.Printf("\n--- Zone: %s ---\n", zone.ZoneID)
for _, domain := range zone.Domains {
updateDomain(zone.ZoneID, currentIP, domain)
}
}
}