services -> domains

This commit is contained in:
2026-07-10 20:46:22 +00:00
parent 3c99d26830
commit 79c0c32b98
45 changed files with 1447 additions and 1270 deletions

View File

@@ -13,14 +13,18 @@ import (
"time"
)
// Config holds the DDNS configuration
type Config struct {
Enabled bool
// 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"`
@@ -39,13 +43,16 @@ type Status struct {
Records []RecordStatus `json:"records,omitempty"`
}
// Runner manages the DDNS background goroutine and exposes current state
// 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
ipFetcher func() (string, error) // injectable for tests
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
@@ -56,27 +63,30 @@ func NewRunner() *Runner {
}
}
// Start launches or restarts the DDNS goroutine with the given config.
// Start launches the DDNS goroutine with a params callback.
// 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) {
func (rn *Runner) Start(parentCtx context.Context, paramsFn ParamsFunc) {
rn.mu.Lock()
if rn.cancel != nil {
rn.cancel()
rn.cancel = nil
}
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 {
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.status.CurrentIP = "" // clear so first check always syncs all records
rn.mu.Unlock()
go rn.Run(ctx, cfg)
go rn.run(ctx)
}
// Stop cancels the running DDNS goroutine if one is active.
@@ -97,7 +107,7 @@ func (rn *Runner) GetStatus() Status {
return rn.status
}
// Trigger requests an immediate IP check and update (non-blocking)
// Trigger requests an immediate check with fresh params (non-blocking)
func (rn *Runner) Trigger() {
select {
case rn.trigger <- struct{}{}:
@@ -105,23 +115,19 @@ func (rn *Runner) Trigger() {
}
}
// 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
}
// 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(cfg.IntervalMinutes) * time.Minute
interval := time.Duration(p.IntervalMinutes) * time.Minute
if interval <= 0 {
interval = 5 * time.Minute
}
slog.Info("DDNS started", "component", "ddns", "records", cfg.Records, "interval", interval)
slog.Info("DDNS started", "component", "ddns", "records", p.Records, "interval", interval)
// Check immediately on start
rn.checkAndUpdate(cfg)
rn.checkAndUpdate()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -132,21 +138,27 @@ func (rn *Runner) Run(ctx context.Context, cfg Config) {
slog.Info("DDNS stopped", "component", "ddns")
return
case <-ticker.C:
rn.checkAndUpdate(cfg)
rn.checkAndUpdate()
case <-rn.trigger:
rn.checkAndUpdate(cfg)
rn.checkAndUpdate()
}
}
}
// 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) {
// 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()
@@ -156,21 +168,12 @@ func (rn *Runner) checkAndUpdate(cfg Config) {
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)
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.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 {
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()})

View File

@@ -36,78 +36,12 @@ func TestTrigger_NonBlocking(t *testing.T) {
}
}
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 staticParams(token string, records []string) ParamsFunc {
return func() Params {
return Params{APIToken: token, Records: records, IntervalMinutes: 5}
}
}
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)
@@ -119,8 +53,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
return "1.2.3.4", nil
}
ctx := context.Background()
rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
rn.Start(context.Background(), staticParams("tok", []string{"a.example.com"}))
select {
case <-started:
@@ -129,7 +62,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
}
if !rn.GetStatus().Enabled {
t.Error("expected Enabled=true after Start with valid config")
t.Error("expected Enabled=true after Start with valid params")
}
rn.Stop()
@@ -139,23 +72,20 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
}
}
// 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},
func TestStart_InvalidParams_SetsDisabled(t *testing.T) {
for _, pfn := range []ParamsFunc{
func() Params { return Params{} },
func() Params { return Params{APIToken: ""} },
func() Params { return Params{APIToken: "tok", Records: nil} },
} {
rn := NewRunner()
rn.Start(context.Background(), cfg)
rn.Start(context.Background(), pfn)
if rn.GetStatus().Enabled {
t.Errorf("expected Enabled=false for config %+v", cfg)
t.Errorf("expected Enabled=false for empty params")
}
}
}
// 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)
@@ -167,11 +97,9 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
return "1.2.3.4", nil
}
ctx := context.Background()
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
rn.Start(ctx, cfg)
pfn := staticParams("tok", []string{"a.example.com"})
rn.Start(context.Background(), pfn)
// Wait for first goroutine to actually start
select {
case <-reached:
case <-time.After(time.Second):
@@ -179,9 +107,8 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
}
// Restart — old goroutine is cancelled, new one starts
rn.Start(ctx, cfg)
rn.Start(context.Background(), pfn)
// Enabled must still be true regardless of old goroutine's exit timing
if !rn.GetStatus().Enabled {
t.Error("expected Enabled=true after restart")
}
@@ -189,8 +116,6 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
rn.Stop()
}
// TestStop_ClearsEnabled verifies Stop sets Enabled=false.
func TestStop_ClearsEnabled(t *testing.T) {
rn := NewRunner()
rn.mu.Lock()
@@ -204,7 +129,6 @@ func TestStop_ClearsEnabled(t *testing.T) {
}
}
// TestStart_CancelsAndRestarts verifies calling Start twice cancels the previous goroutine.
func TestStart_CancelsAndRestarts(t *testing.T) {
rn := NewRunner()
@@ -219,10 +143,8 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
return "1.2.3.4", nil
}
ctx := context.Background()
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
rn.Start(ctx, cfg)
pfn := staticParams("tok", []string{"a.example.com"})
rn.Start(context.Background(), pfn)
select {
case <-firstReady:
@@ -230,13 +152,12 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
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
// Second Start with disabled params must not deadlock
done := make(chan struct{})
go func() {
rn.Start(ctx, Config{Enabled: false})
rn.Start(context.Background(), func() Params { return Params{} })
close(done)
}()
@@ -247,59 +168,45 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
}
}
// 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.paramsFn = staticParams("fake", []string{"a.example.com"})
rn.checkAndUpdate(Config{APIToken: "fake", Records: []string{"a.example.com"}})
rn.checkAndUpdate()
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) {
func TestCheckAndUpdate_SkipsWhenNoParams(t *testing.T) {
rn := NewRunner()
rn.mu.Lock()
rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine
rn.mu.Unlock()
rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
rn.paramsFn = func() Params { return Params{} }
rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
rn.Stop()
rn.checkAndUpdate()
if rn.GetStatus().CurrentIP != "" {
t.Error("Start should clear CurrentIP so the first check syncs all records")
t.Error("expected empty CurrentIP when params have no token/records")
}
}
// TestCheckAndUpdate_SkipsWhenIPUnchanged verifies no Cloudflare call when IP matches.
func TestCheckAndUpdate_SkipsWhenIPUnchanged(t *testing.T) {
func TestCheckAndUpdate_ParamsFuncCalledEachTime(t *testing.T) {
rn := NewRunner()
rn.mu.Lock()
rn.status.CurrentIP = "9.9.9.9"
rn.mu.Unlock()
rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
calls := 0
rn.ipFetcher = func() (string, error) {
rn.paramsFn = func() Params {
calls++
return "9.9.9.9", nil
return Params{APIToken: "fake", Records: []string{"a.example.com"}}
}
prevUpdated := rn.GetStatus().LastUpdated
rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}})
rn.checkAndUpdate()
rn.checkAndUpdate()
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)
if calls != 2 {
t.Errorf("expected paramsFn called 2 times, got %d", calls)
}
}