From 98b8e20265d5a68d50b83e328392caab4d402434 Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Sun, 8 Mar 2026 02:16:42 +0900 Subject: [PATCH] init --- README.md | 5 ++ go.mod | 5 ++ go.sum | 2 + main.go | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..bae3e7b --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Cloudflare DNS Updater + +For home servers with dynamic IPs + +Build the go project, then run the binary on a cron or whatever. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fa275b1 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module cloudflare-dns-updater + +go 1.25.0 + +require github.com/joho/godotenv v1.5.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..051ddd8 --- /dev/null +++ b/main.go @@ -0,0 +1,258 @@ +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) + } + } +}