407 lines
12 KiB
Go
407 lines
12 KiB
Go
package ddns
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Params holds the runtime parameters for DDNS sync — derived from system state
|
|
// (public domains, API token, interval) on each tick via ParamsFunc.
|
|
type Params struct {
|
|
APIToken string
|
|
Records []string
|
|
IntervalMinutes int
|
|
}
|
|
|
|
// ParamsFunc returns the current DDNS parameters. Called on each tick so the
|
|
// runner always has fresh state (new tokens, new public domains, etc.).
|
|
type ParamsFunc func() Params
|
|
|
|
// RecordStatus holds the last-known sync result for a single DNS record.
|
|
type RecordStatus struct {
|
|
Name string `json:"name"`
|
|
IP string `json:"ip,omitempty"`
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Status holds the current DDNS state for the status endpoint
|
|
type Status struct {
|
|
Enabled bool `json:"enabled"`
|
|
CurrentIP string `json:"currentIP"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
LastUpdated time.Time `json:"lastUpdated"`
|
|
LastError string `json:"lastError,omitempty"`
|
|
Records []RecordStatus `json:"records,omitempty"`
|
|
}
|
|
|
|
// Runner manages the DDNS background goroutine and exposes current state.
|
|
// The runner pulls fresh parameters via ParamsFunc on each tick, so token
|
|
// and record changes take effect without restarting the goroutine.
|
|
type Runner struct {
|
|
mu sync.RWMutex
|
|
status Status
|
|
trigger chan struct{}
|
|
cancel context.CancelFunc
|
|
paramsFn ParamsFunc
|
|
ipFetcher func() (string, error) // injectable for tests
|
|
}
|
|
|
|
// NewRunner creates a new DDNS runner
|
|
func NewRunner() *Runner {
|
|
return &Runner{
|
|
trigger: make(chan struct{}, 1),
|
|
ipFetcher: getPublicIP,
|
|
}
|
|
}
|
|
|
|
// Start launches the DDNS goroutine with a params callback.
|
|
// Safe to call multiple times — cancels the previous run first.
|
|
func (rn *Runner) Start(parentCtx context.Context, paramsFn ParamsFunc) {
|
|
rn.mu.Lock()
|
|
if rn.cancel != nil {
|
|
rn.cancel()
|
|
rn.cancel = nil
|
|
}
|
|
rn.paramsFn = paramsFn
|
|
|
|
// Validate initial params
|
|
p := paramsFn()
|
|
if p.APIToken == "" || len(p.Records) == 0 {
|
|
rn.status.Enabled = false
|
|
rn.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(parentCtx)
|
|
rn.cancel = cancel
|
|
rn.status.Enabled = true
|
|
rn.mu.Unlock()
|
|
|
|
go rn.run(ctx)
|
|
}
|
|
|
|
// Stop cancels the running DDNS goroutine if one is active.
|
|
func (rn *Runner) Stop() {
|
|
rn.mu.Lock()
|
|
defer rn.mu.Unlock()
|
|
if rn.cancel != nil {
|
|
rn.cancel()
|
|
rn.cancel = nil
|
|
}
|
|
rn.status.Enabled = false
|
|
}
|
|
|
|
// GetStatus returns a snapshot of the current DDNS status
|
|
func (rn *Runner) GetStatus() Status {
|
|
rn.mu.RLock()
|
|
defer rn.mu.RUnlock()
|
|
return rn.status
|
|
}
|
|
|
|
// Trigger requests an immediate check with fresh params (non-blocking)
|
|
func (rn *Runner) Trigger() {
|
|
select {
|
|
case rn.trigger <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// run is the DDNS polling loop. Calls paramsFn on each tick for fresh state.
|
|
func (rn *Runner) run(ctx context.Context) {
|
|
p := rn.paramsFn()
|
|
|
|
interval := time.Duration(p.IntervalMinutes) * time.Minute
|
|
if interval <= 0 {
|
|
interval = 5 * time.Minute
|
|
}
|
|
|
|
slog.Info("DDNS started", "component", "ddns", "records", p.Records, "interval", interval)
|
|
|
|
// Check immediately on start
|
|
rn.checkAndUpdate()
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("DDNS stopped", "component", "ddns")
|
|
return
|
|
case <-ticker.C:
|
|
rn.checkAndUpdate()
|
|
case <-rn.trigger:
|
|
rn.checkAndUpdate()
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
|
|
// The updateCloudflareRecord function short-circuits when the A record already matches,
|
|
// so there's no wasted API calls when nothing has changed.
|
|
func (rn *Runner) checkAndUpdate() {
|
|
rn.mu.Lock()
|
|
rn.status.LastChecked = time.Now()
|
|
rn.mu.Unlock()
|
|
|
|
p := rn.paramsFn()
|
|
if p.APIToken == "" || len(p.Records) == 0 {
|
|
slog.Debug("DDNS: no token or records, skipping", "component", "ddns")
|
|
return
|
|
}
|
|
|
|
ip, err := rn.ipFetcher()
|
|
if err != nil {
|
|
rn.mu.Lock()
|
|
rn.status.LastError = fmt.Sprintf("getting public IP: %v", err)
|
|
rn.mu.Unlock()
|
|
slog.Error("DDNS: failed to get public IP", "component", "ddns", "error", err)
|
|
return
|
|
}
|
|
|
|
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
|
|
|
|
var lastErr string
|
|
records := make([]RecordStatus, 0, len(p.Records))
|
|
for _, record := range p.Records {
|
|
if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil {
|
|
lastErr = fmt.Sprintf("updating %s: %v", record, err)
|
|
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err)
|
|
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})
|
|
} else {
|
|
records = append(records, RecordStatus{Name: record, IP: ip, OK: true})
|
|
}
|
|
}
|
|
|
|
rn.mu.Lock()
|
|
rn.status.CurrentIP = ip
|
|
rn.status.LastUpdated = time.Now()
|
|
rn.status.LastError = lastErr
|
|
rn.status.Records = records
|
|
rn.mu.Unlock()
|
|
}
|
|
|
|
// getPublicIP returns the current public IPv4 address.
|
|
// Tries Cloudflare's trace endpoint first, falls back to ipify.org.
|
|
func getPublicIP() (string, error) {
|
|
if ip, err := ipFromCloudflareTrace(); err == nil {
|
|
return ip, nil
|
|
}
|
|
return ipFromURL("https://api.ipify.org")
|
|
}
|
|
|
|
// ipFromCloudflareTrace parses the ip= field from https://cloudflare.com/cdn-cgi/trace
|
|
func ipFromCloudflareTrace() (string, error) {
|
|
resp, err := http.Get("https://cloudflare.com/cdn-cgi/trace")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for line := range strings.SplitSeq(string(body), "\n") {
|
|
if ip, ok := strings.CutPrefix(line, "ip="); ok {
|
|
ip = strings.TrimSpace(ip)
|
|
if ip != "" {
|
|
return ip, nil
|
|
}
|
|
}
|
|
}
|
|
return "", fmt.Errorf("ip not found in cloudflare trace response")
|
|
}
|
|
|
|
func ipFromURL(url string) (string, error) {
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetching public IP from %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading response from %s: %w", url, err)
|
|
}
|
|
ip := strings.TrimSpace(string(body))
|
|
if ip == "" {
|
|
return "", fmt.Errorf("empty response from %s", url)
|
|
}
|
|
return ip, nil
|
|
}
|
|
|
|
// cloudflareRecord is used to find the record ID for a given hostname
|
|
type cloudflareRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// updateCloudflareRecord updates a single A record via the Cloudflare API.
|
|
// The record name must be a fully qualified domain name (e.g. "cloud.payne.io").
|
|
// If a CNAME already exists at that name (e.g. from a router DDNS service), it is
|
|
// deleted first so the A record can be created without conflict.
|
|
func updateCloudflareRecord(apiToken, recordName, ip string) error {
|
|
// Extract zone name: last two parts of the FQDN (e.g. "payne.io" from "cloud.payne.io")
|
|
parts := strings.Split(recordName, ".")
|
|
if len(parts) < 2 {
|
|
return fmt.Errorf("invalid record name: %s", recordName)
|
|
}
|
|
zoneName := strings.Join(parts[len(parts)-2:], ".")
|
|
|
|
// Get zone ID
|
|
zoneID, err := getCloudflareZoneID(apiToken, zoneName)
|
|
if err != nil {
|
|
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
|
|
}
|
|
|
|
// Check for existing A record — patch it if found
|
|
recordID, currentIP, err := getCloudflareRecordID(apiToken, zoneID, recordName, "A")
|
|
if err == nil {
|
|
if currentIP == ip {
|
|
return nil // already up to date
|
|
}
|
|
return patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip)
|
|
}
|
|
|
|
// No A record. Remove any CNAME that would conflict with A record creation
|
|
// (common when migrating from a router-based DDNS service like GL.iNet).
|
|
if cnameID, _, cnameErr := getCloudflareRecordID(apiToken, zoneID, recordName, "CNAME"); cnameErr == nil {
|
|
slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName)
|
|
if err := deleteCloudflareRecord(apiToken, zoneID, cnameID); err != nil {
|
|
return fmt.Errorf("removing CNAME for %s: %w", recordName, err)
|
|
}
|
|
}
|
|
|
|
slog.Info("DDNS: creating A record", "component", "ddns", "record", recordName)
|
|
return createCloudflareRecord(apiToken, zoneID, recordName, ip)
|
|
}
|
|
|
|
// createCloudflareRecord creates a new A record via the Cloudflare API
|
|
func createCloudflareRecord(apiToken, zoneID, recordName, ip string) error {
|
|
body, _ := json.Marshal(map[string]string{
|
|
"type": "A",
|
|
"name": recordName,
|
|
"content": ip,
|
|
})
|
|
req, _ := http.NewRequest(http.MethodPost,
|
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
|
|
bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+apiToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("creating record: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// patchCloudflareRecord updates an existing A record via the Cloudflare API
|
|
func patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip string) error {
|
|
body, _ := json.Marshal(map[string]string{
|
|
"type": "A",
|
|
"name": recordName,
|
|
"content": ip,
|
|
})
|
|
req, _ := http.NewRequest(http.MethodPatch,
|
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
|
bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+apiToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("updating record: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
|
|
req, _ := http.NewRequest(http.MethodGet,
|
|
"https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
|
|
req.Header.Set("Authorization", "Bearer "+apiToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Result []struct {
|
|
ID string `json:"id"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", err
|
|
}
|
|
if len(result.Result) == 0 {
|
|
return "", fmt.Errorf("zone %q not found", zoneName)
|
|
}
|
|
return result.Result[0].ID, nil
|
|
}
|
|
|
|
func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, currentContent string, err error) {
|
|
req, _ := http.NewRequest(http.MethodGet,
|
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, recordName), nil)
|
|
req.Header.Set("Authorization", "Bearer "+apiToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Result []cloudflareRecord `json:"result"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", "", err
|
|
}
|
|
if len(result.Result) == 0 {
|
|
return "", "", fmt.Errorf("%s record %q not found", recordType, recordName)
|
|
}
|
|
return result.Result[0].ID, result.Result[0].Content, nil
|
|
}
|
|
|
|
func deleteCloudflareRecord(apiToken, zoneID, recordID string) error {
|
|
req, _ := http.NewRequest(http.MethodDelete,
|
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
|
nil)
|
|
req.Header.Set("Authorization", "Bearer "+apiToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting record: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
return nil
|
|
}
|