init
This commit is contained in:
5
README.md
Normal file
5
README.md
Normal file
@@ -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.
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module cloudflare-dns-updater
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/joho/godotenv v1.5.1 // indirect
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -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=
|
||||
258
main.go
Normal file
258
main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user