services -> domains
This commit is contained in:
@@ -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()})
|
||||
|
||||
Reference in New Issue
Block a user