259 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|