feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
403
internal/ddns/cloudflare.go
Normal file
403
internal/ddns/cloudflare.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the DDNS configuration
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
APIToken string
|
||||
Records []string
|
||||
IntervalMinutes int
|
||||
}
|
||||
|
||||
// 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
|
||||
type Runner struct {
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
trigger chan struct{}
|
||||
cancel context.CancelFunc
|
||||
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 or restarts the DDNS goroutine with the given config.
|
||||
// Safe to call multiple times — cancels the previous run first.
|
||||
// Enabled is set to true here (not inside Run) to eliminate the race where
|
||||
// the old goroutine's exit could clear Enabled after the new goroutine starts.
|
||||
func (rn *Runner) Start(parentCtx context.Context, cfg Config) {
|
||||
rn.mu.Lock()
|
||||
if rn.cancel != nil {
|
||||
rn.cancel()
|
||||
rn.cancel = nil
|
||||
}
|
||||
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 {
|
||||
rn.status.Enabled = false
|
||||
rn.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
rn.cancel = cancel
|
||||
rn.status.Enabled = true
|
||||
rn.status.CurrentIP = "" // clear so first check always syncs all records
|
||||
rn.mu.Unlock()
|
||||
go rn.Run(ctx, cfg)
|
||||
}
|
||||
|
||||
// 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 IP check and update (non-blocking)
|
||||
func (rn *Runner) Trigger() {
|
||||
select {
|
||||
case rn.trigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the DDNS polling loop. It exits when ctx is cancelled.
|
||||
// Enabled state is managed by Start/Stop, not this function.
|
||||
func (rn *Runner) Run(ctx context.Context, cfg Config) {
|
||||
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 {
|
||||
slog.Info("DDNS disabled or not configured", "component", "ddns")
|
||||
return
|
||||
}
|
||||
|
||||
interval := time.Duration(cfg.IntervalMinutes) * time.Minute
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
|
||||
slog.Info("DDNS started", "component", "ddns", "records", cfg.Records, "interval", interval)
|
||||
|
||||
// Check immediately on start
|
||||
rn.checkAndUpdate(cfg)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("DDNS stopped", "component", "ddns")
|
||||
return
|
||||
case <-ticker.C:
|
||||
rn.checkAndUpdate(cfg)
|
||||
case <-rn.trigger:
|
||||
rn.checkAndUpdate(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndUpdate fetches the current public IP and reconciles all records.
|
||||
// Records are always verified against Cloudflare, not just when the IP changes,
|
||||
// so external drift (e.g. a router DDNS overwriting with a CNAME) is self-healed.
|
||||
func (rn *Runner) checkAndUpdate(cfg Config) {
|
||||
rn.mu.Lock()
|
||||
rn.status.LastChecked = time.Now()
|
||||
rn.mu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
rn.mu.RLock()
|
||||
ipChanged := rn.status.CurrentIP != ip
|
||||
rn.mu.RUnlock()
|
||||
|
||||
if !ipChanged {
|
||||
slog.Debug("DDNS: IP unchanged, skipping update", "component", "ddns", "ip", ip)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("DDNS: IP changed, updating records", "component", "ddns", "newIP", ip, "records", cfg.Records)
|
||||
|
||||
var lastErr string
|
||||
records := make([]RecordStatus, 0, len(cfg.Records))
|
||||
for _, record := range cfg.Records {
|
||||
if err := updateCloudflareRecord(cfg.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
|
||||
}
|
||||
321
internal/ddns/cloudflare_test.go
Normal file
321
internal/ddns/cloudflare_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewRunner_ZeroStatus(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
s := rn.GetStatus()
|
||||
if s.Enabled {
|
||||
t.Error("new runner should have Enabled=false")
|
||||
}
|
||||
if s.CurrentIP != "" {
|
||||
t.Errorf("new runner should have empty CurrentIP, got %q", s.CurrentIP)
|
||||
}
|
||||
if !s.LastChecked.IsZero() {
|
||||
t.Error("new runner should have zero LastChecked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrigger_NonBlocking(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Trigger()
|
||||
rn.Trigger()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Trigger blocked unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ExitsOnContextCancel(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{}) // not enabled — returns immediately
|
||||
close(done)
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run did not exit after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_DisabledConfig_ExitsImmediately(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{Enabled: false})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run should exit immediately when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_NoToken_ExitsImmediately(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{Enabled: true, APIToken: "", Records: []string{"cloud.example.com"}})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run should exit immediately when no API token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_NoRecords_ExitsImmediately(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{Enabled: true, APIToken: "token", Records: nil})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run should exit immediately when no records configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_SetsEnabledStatus verifies Enabled=true after Start, false after Stop.
|
||||
// Uses Start (not Run directly) since Start owns the Enabled lifecycle.
|
||||
func TestStart_SetsEnabledStatus(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
started := make(chan struct{}, 1)
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
select {
|
||||
case started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return "1.2.3.4", nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("goroutine did not start in time")
|
||||
}
|
||||
|
||||
if !rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=true after Start with valid config")
|
||||
}
|
||||
|
||||
rn.Stop()
|
||||
|
||||
if rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=false after Stop()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_InvalidConfig_SetsDisabled verifies Enabled=false when Start is called with bad config.
|
||||
func TestStart_InvalidConfig_SetsDisabled(t *testing.T) {
|
||||
for _, cfg := range []Config{
|
||||
{Enabled: false},
|
||||
{Enabled: true, APIToken: ""},
|
||||
{Enabled: true, APIToken: "tok", Records: nil},
|
||||
} {
|
||||
rn := NewRunner()
|
||||
rn.Start(context.Background(), cfg)
|
||||
if rn.GetStatus().Enabled {
|
||||
t.Errorf("expected Enabled=false for config %+v", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_Restart_KeepsEnabled verifies that calling Start twice does not
|
||||
// clear Enabled — the race where the old goroutine's exit sets Enabled=false.
|
||||
func TestStart_Restart_KeepsEnabled(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
reached := make(chan struct{}, 2)
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
select {
|
||||
case reached <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return "1.2.3.4", nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
|
||||
rn.Start(ctx, cfg)
|
||||
|
||||
// Wait for first goroutine to actually start
|
||||
select {
|
||||
case <-reached:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first goroutine did not start")
|
||||
}
|
||||
|
||||
// Restart — old goroutine is cancelled, new one starts
|
||||
rn.Start(ctx, cfg)
|
||||
|
||||
// Enabled must still be true regardless of old goroutine's exit timing
|
||||
if !rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=true after restart")
|
||||
}
|
||||
|
||||
rn.Stop()
|
||||
}
|
||||
|
||||
|
||||
// TestStop_ClearsEnabled verifies Stop sets Enabled=false.
|
||||
func TestStop_ClearsEnabled(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.Enabled = true
|
||||
rn.mu.Unlock()
|
||||
|
||||
rn.Stop()
|
||||
|
||||
if rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=false after Stop()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_CancelsAndRestarts verifies calling Start twice cancels the previous goroutine.
|
||||
func TestStart_CancelsAndRestarts(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
|
||||
block := make(chan struct{})
|
||||
firstReady := make(chan struct{}, 1)
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
select {
|
||||
case firstReady <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-block
|
||||
return "1.2.3.4", nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
|
||||
|
||||
rn.Start(ctx, cfg)
|
||||
|
||||
select {
|
||||
case <-firstReady:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first goroutine did not start")
|
||||
}
|
||||
|
||||
// Unblock the first goroutine so the second Start can cancel it
|
||||
close(block)
|
||||
|
||||
// Second Start with disabled config must not deadlock
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Start(ctx, Config{Enabled: false})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("second Start blocked unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAndUpdate_UpdatesCurrentIP verifies CurrentIP is set after IP fetch.
|
||||
// Note: this calls updateCloudflareRecord which fails with a fake token,
|
||||
// but CurrentIP is still updated to reflect the fetched IP.
|
||||
func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil }
|
||||
|
||||
rn.checkAndUpdate(Config{APIToken: "fake", Records: []string{"a.example.com"}})
|
||||
|
||||
if rn.GetStatus().CurrentIP != "5.6.7.8" {
|
||||
t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_ClearsCurrentIP verifies that Start resets CurrentIP so new records are
|
||||
// always synced on the first check, even when the public IP hasn't changed.
|
||||
// This catches the bug where adding a new record (e.g. dev.payne.io) would be
|
||||
// skipped because currentIP already matched the fetched IP.
|
||||
func TestStart_ClearsCurrentIP(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine
|
||||
rn.mu.Unlock()
|
||||
|
||||
rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
|
||||
rn.Stop()
|
||||
|
||||
if rn.GetStatus().CurrentIP != "" {
|
||||
t.Error("Start should clear CurrentIP so the first check syncs all records")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAndUpdate_SkipsWhenIPUnchanged verifies no Cloudflare call when IP matches.
|
||||
func TestCheckAndUpdate_SkipsWhenIPUnchanged(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.CurrentIP = "9.9.9.9"
|
||||
rn.mu.Unlock()
|
||||
|
||||
calls := 0
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
calls++
|
||||
return "9.9.9.9", nil
|
||||
}
|
||||
|
||||
prevUpdated := rn.GetStatus().LastUpdated
|
||||
rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}})
|
||||
|
||||
if rn.GetStatus().LastUpdated != prevUpdated {
|
||||
t.Error("LastUpdated should not change when IP is unchanged")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 ipFetcher call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_ReflectsManualUpdate(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.CurrentIP = "1.2.3.4"
|
||||
rn.status.LastChecked = time.Now()
|
||||
rn.status.Enabled = true
|
||||
rn.mu.Unlock()
|
||||
|
||||
s := rn.GetStatus()
|
||||
if s.CurrentIP != "1.2.3.4" {
|
||||
t.Errorf("got CurrentIP %q, want 1.2.3.4", s.CurrentIP)
|
||||
}
|
||||
if !s.Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user