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