diff --git a/cmd/dipper_ai/main.go b/cmd/dipper_ai/main.go index 7acbc8b..5d1f482 100644 --- a/cmd/dipper_ai/main.go +++ b/cmd/dipper_ai/main.go @@ -12,8 +12,10 @@ import ( const usage = `Usage: dipper_ai Commands: - update Fetch IP, update DDNS if changed - check Check current IP and DDNS status + daemon Run as a long-lived daemon (normal operation, managed by systemd) + update Fetch IP, update DDNS if changed (manual / one-shot) + check Check current IP and DDNS status (manual / one-shot) + keepalive Force-update all DDNS providers (manual / one-shot) err_mail Aggregate errors and send notification if threshold met ` @@ -40,10 +42,14 @@ func main() { var runErr error switch cmd { + case "daemon": + runErr = mode.Daemon(cfg) case "update": runErr = mode.Update(cfg) case "check": runErr = mode.Check(cfg) + case "keepalive": + runErr = mode.Keepalive(cfg) case "err_mail": runErr = mode.ErrMail(cfg) default: diff --git a/internal/mode/daemon.go b/internal/mode/daemon.go new file mode 100644 index 0000000..364932c --- /dev/null +++ b/internal/mode/daemon.go @@ -0,0 +1,92 @@ +package mode + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Liplus-Project/dipper_ai/internal/config" +) + +const ( + defaultCheckInterval = 5 * time.Minute + startupDelay = 10 * time.Second +) + +// Daemon runs dipper_ai as a long-lived process with two independent tickers: +// - Check ticker (DDNS_TIME interval): fetch IP → update if changed → DNS verify +// - Keepalive ticker (UPDATE_TIME interval): force-update all MyDNS entries +// +// Design rationale: +// Both intervals are handled internally by goroutine tickers, so any +// combination of DDNS_TIME and UPDATE_TIME works correctly — including +// DDNS_TIME=1d with UPDATE_TIME=2m, which was impossible with the previous +// single-timer systemd approach. +// +// A single process also means a single log stream: `journalctl -u dipper_ai` +// shows all activity without needing to distinguish between timer units. +// +// Shutdown: SIGTERM or SIGINT triggers a clean exit. +func Daemon(cfg *config.Config) error { + // --- Check interval --- + checkInterval := time.Duration(cfg.DDNSTime) * time.Minute + if checkInterval <= 0 { + checkInterval = defaultCheckInterval + } + + fmt.Fprintf(os.Stderr, "dipper_ai daemon: starting (check=%v", checkInterval) + if cfg.UpdateTime > 0 { + fmt.Fprintf(os.Stderr, ", keepalive=%v", time.Duration(cfg.UpdateTime)*time.Minute) + } else { + fmt.Fprintf(os.Stderr, ", keepalive=disabled") + } + fmt.Fprintf(os.Stderr, ")\n") + + // Short startup delay — gives the network stack time to come up after boot. + time.Sleep(startupDelay) + + // Run first cycle immediately on startup. + runCycle(cfg) + + checkTicker := time.NewTicker(checkInterval) + defer checkTicker.Stop() + + // Keepalive ticker — nil channel blocks forever when keepalive is disabled. + var keepaliveCh <-chan time.Time + if cfg.UpdateTime > 0 { + kt := time.NewTicker(time.Duration(cfg.UpdateTime) * time.Minute) + defer kt.Stop() + keepaliveCh = kt.C + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + + for { + select { + case <-checkTicker.C: + runCycle(cfg) + case <-keepaliveCh: + _ = Keepalive(cfg) + case sig := <-sigCh: + fmt.Fprintf(os.Stderr, "dipper_ai daemon: received %v, shutting down\n", sig) + return nil + } + } +} + +// runCycle executes one full check-and-update cycle: update → check → err_mail. +func runCycle(cfg *config.Config) { + if err := Update(cfg); err != nil { + // Update() already logged the error; non-fatal for the daemon. + _ = err + } + if err := Check(cfg); err != nil { + _ = err + } + if err := ErrMail(cfg); err != nil { + _ = err + } +} diff --git a/internal/mode/keepalive.go b/internal/mode/keepalive.go new file mode 100644 index 0000000..b5936f4 --- /dev/null +++ b/internal/mode/keepalive.go @@ -0,0 +1,125 @@ +package mode + +import ( + "fmt" + "os" + "strings" + + "github.com/Liplus-Project/dipper_ai/internal/config" + "github.com/Liplus-Project/dipper_ai/internal/ddns" + "github.com/Liplus-Project/dipper_ai/internal/state" +) + +// Keepalive force-updates all MyDNS entries regardless of IP change. +// Equivalent to `dipper_ai keepalive`. +// +// Logic: +// - Called by the daemon's keepalive ticker (UPDATE_TIME interval), +// fully independent of the check/update ticker (DDNS_TIME interval). +// - Fetches current external IP (needed to populate the DDNS request). +// - All MyDNS entries are updated unconditionally; domain cache is refreshed. +// - Cloudflare is skipped — its records persist without periodic refresh. +// - Sends email notification when EMAIL_UP_DDNS=on. +func Keepalive(cfg *config.Config) error { + st, err := state.New(cfg.StateDir) + if err != nil { + return err + } + + // --- Fetch current external IP --- + wantV4 := cfg.IPv4 && cfg.IPv4DDNS + wantV6 := cfg.IPv6 && cfg.IPv6DDNS + fetched, _ := ipFetch(wantV4, wantV6) + + if wantV4 && fetched.ErrIPv4 != nil { + _ = st.AppendError(fmt.Sprintf("ip_fetch_error ipv4: %v", fetched.ErrIPv4)) + fmt.Fprintf(os.Stderr, "dipper_ai keepalive: IPv4 fetch failed: %v\n", fetched.ErrIPv4) + } + if wantV6 && fetched.ErrIPv6 != nil { + _ = st.AppendError(fmt.Sprintf("ip_fetch_error ipv6: %v", fetched.ErrIPv6)) + fmt.Fprintf(os.Stderr, "dipper_ai keepalive: IPv6 fetch failed: %v\n", fetched.ErrIPv6) + } + if fetched.IPv4 == "" && fetched.IPv6 == "" && (wantV4 || wantV6) { + if fetched.ErrIPv4 != nil { + return fetched.ErrIPv4 + } + return fetched.ErrIPv6 + } + + var keepaliveErr error + var successLines []string + + // --- MyDNS per-entry force update --- + for i, entry := range cfg.MyDNS { + entryKey := fmt.Sprintf("mydns_%d", i) + dnsEntry := ddns.MyDNSEntry{ + ID: entry.ID, + Pass: entry.Pass, + Domain: entry.Domain, + } + + if wantV4 && entry.IPv4 && fetched.IPv4 != "" { + r := mydnsUpdateIPv4(dnsEntry, cfg.MyDNSIPv4URL) + if r.Err != nil { + _ = st.WriteDDNSResult(entryKey+"_ipv4", "fail:"+r.Err.Error()) + _ = st.AppendError(fmt.Sprintf("ddns_error mydns[%d] ipv4: %v", i, r.Err)) + fmt.Fprintf(os.Stderr, "dipper_ai keepalive: mydns[%d] %s ipv4: FAIL: %v\n", i, entry.Domain, r.Err) + keepaliveErr = r.Err + } else { + _ = st.WriteDomainCache(entryKey, "ipv4", fetched.IPv4) + _ = st.WriteDDNSResult(entryKey+"_ipv4", "ok") + successLines = append(successLines, fmt.Sprintf(" mydns[%d] %s ipv4: ok", i, entry.Domain)) + } + } + + if wantV6 && entry.IPv6 && fetched.IPv6 != "" { + r := mydnsUpdateIPv6(dnsEntry, cfg.MyDNSIPv6URL) + if r.Err != nil { + _ = st.WriteDDNSResult(entryKey+"_ipv6", "fail:"+r.Err.Error()) + _ = st.AppendError(fmt.Sprintf("ddns_error mydns[%d] ipv6: %v", i, r.Err)) + fmt.Fprintf(os.Stderr, "dipper_ai keepalive: mydns[%d] %s ipv6: FAIL: %v\n", i, entry.Domain, r.Err) + keepaliveErr = r.Err + } else { + _ = st.WriteDomainCache(entryKey, "ipv6", fetched.IPv6) + _ = st.WriteDDNSResult(entryKey+"_ipv6", "ok") + successLines = append(successLines, fmt.Sprintf(" mydns[%d] %s ipv6: ok", i, entry.Domain)) + } + } + } + + // Cloudflare: no keepalive needed — records persist without periodic refresh. + + if len(successLines) > 0 { + if fetched.IPv4 != "" { + fmt.Fprintf(os.Stderr, "dipper_ai keepalive: IPv4=%s\n", fetched.IPv4) + } + if fetched.IPv6 != "" { + fmt.Fprintf(os.Stderr, "dipper_ai keepalive: IPv6=%s\n", fetched.IPv6) + } + for _, line := range successLines { + fmt.Fprintf(os.Stderr, "dipper_ai keepalive:%s\n", line) + } + } + + // --- Email notification --- + if cfg.EmailAddr != "" && len(successLines) > 0 && cfg.EmailUpDDNS { + subject := "dipper_ai: DDNS keepalive" + var ipLines []string + if fetched.IPv4 != "" { + ipLines = append(ipLines, "IPv4: "+fetched.IPv4) + } + if fetched.IPv6 != "" { + ipLines = append(ipLines, "IPv6: "+fetched.IPv6) + } + body := fmt.Sprintf("%s\n\nReason: keepalive\n\nUpdated providers:\n%s\n", + strings.Join(ipLines, "\n"), + strings.Join(successLines, "\n"), + ) + if mailErr := sendMailFn(cfg.EmailAddr, subject, body); mailErr != nil { + _ = st.AppendError(fmt.Sprintf("keepalive_mail_failed: %v", mailErr)) + fmt.Fprintf(os.Stderr, "dipper_ai keepalive: mail notification failed: %v\n", mailErr) + } + } + + return keepaliveErr +} diff --git a/internal/mode/keepalive_test.go b/internal/mode/keepalive_test.go new file mode 100644 index 0000000..3a576b5 --- /dev/null +++ b/internal/mode/keepalive_test.go @@ -0,0 +1,109 @@ +package mode + +import ( + "strings" + "testing" + + "github.com/Liplus-Project/dipper_ai/internal/config" + "github.com/Liplus-Project/dipper_ai/internal/ddns" +) + +// TestKeepalive_ForceUpdate verifies that Keepalive always sends DDNS updates +// for all MyDNS entries regardless of whether the IP has changed. +func TestKeepalive_ForceUpdate(t *testing.T) { + cfg := baseCfg(t) + cfg.MyDNS = []config.MyDNSEntry{{ID: "id0", Pass: "pass0", Domain: "home.example.com", IPv4: true}} + + overrideFetch(t, fakeFetch("1.2.3.4", "")) + calls := captureMyDNSCalls(t) + + // First call — seeds the domain cache. + if err := Keepalive(cfg); err != nil { + t.Fatalf("first keepalive: %v", err) + } + after1 := len(*calls) + if after1 == 0 { + t.Fatal("expected DDNS call on first keepalive") + } + + // Second call — same IP, but Keepalive always fires unconditionally. + if err := Keepalive(cfg); err != nil { + t.Fatalf("second keepalive: %v", err) + } + if len(*calls) <= after1 { + t.Errorf("expected DDNS call on second keepalive (force), got none") + } +} + +// TestKeepalive_CloudflareSkipped verifies that Cloudflare entries are never +// updated by Keepalive — only MyDNS providers need periodic keepalive. +func TestKeepalive_CloudflareSkipped(t *testing.T) { + cfg := baseCfg(t) + cfg.MyDNS = []config.MyDNSEntry{{ID: "id0", Pass: "pass0", Domain: "home.example.com", IPv4: true}} + + cfCalls := &[]string{} + origCF := cloudflareUpdate + cloudflareUpdate = func(e ddns.CloudflareEntry, ip, recType, url string) ddns.ProviderResult { + *cfCalls = append(*cfCalls, recType+":"+e.Domain) + return ddns.ProviderResult{} + } + t.Cleanup(func() { cloudflareUpdate = origCF }) + + cfg.Cloudflare = []config.CloudflareEntry{ + {Enabled: true, API: "tok", Zone: "example.com", Domain: "home.example.com", IPv4: true}, + } + + overrideFetch(t, fakeFetch("1.2.3.4", "")) + captureMyDNSCalls(t) // mock MyDNS so it doesn't make real HTTP calls + + if err := Keepalive(cfg); err != nil { + t.Fatalf("keepalive: %v", err) + } + if len(*cfCalls) != 0 { + t.Errorf("Cloudflare must NOT be called by Keepalive; got %d call(s)", len(*cfCalls)) + } +} + +// TestKeepalive_Mail verifies that EMAIL_UP_DDNS=on sends a notification after +// a successful keepalive run. +func TestKeepalive_Mail(t *testing.T) { + cfg := baseCfg(t) + cfg.EmailAddr = "test@example.com" + cfg.EmailUpDDNS = true + cfg.MyDNS = []config.MyDNSEntry{{ID: "id0", Pass: "pass0", Domain: "home.example.com", IPv4: true}} + + overrideFetch(t, fakeFetch("1.2.3.4", "")) + captureMyDNSCalls(t) + sent := captureMailCalls(t) + + if err := Keepalive(cfg); err != nil { + t.Fatalf("keepalive: %v", err) + } + if len(*sent) == 0 { + t.Fatal("expected mail when EMAIL_UP_DDNS=true") + } + mail := (*sent)[0] + if !strings.Contains(mail, "keepalive") { + t.Errorf("mail body should mention keepalive, got: %s", mail) + } +} + +// TestKeepalive_MailOffWhenDisabled verifies that EMAIL_UP_DDNS=false suppresses +// the keepalive notification. +func TestKeepalive_MailOffWhenDisabled(t *testing.T) { + cfg := baseCfg(t) + cfg.EmailAddr = "test@example.com" + cfg.EmailUpDDNS = false + cfg.MyDNS = []config.MyDNSEntry{{ID: "id0", Pass: "pass0", Domain: "home.example.com", IPv4: true}} + + overrideFetch(t, fakeFetch("1.2.3.4", "")) + captureMyDNSCalls(t) + sent := captureMailCalls(t) + + if err := Keepalive(cfg); err != nil { + t.Fatalf("keepalive: %v", err) + } + if len(*sent) != 0 { + t.Errorf("expected no mail when EMAIL_UP_DDNS=false, got %d", len(*sent)) + } +} diff --git a/internal/mode/update.go b/internal/mode/update.go index b8d5f20..c87b4c2 100644 --- a/internal/mode/update.go +++ b/internal/mode/update.go @@ -5,13 +5,11 @@ import ( "fmt" "os" "strings" - "time" "github.com/Liplus-Project/dipper_ai/internal/config" "github.com/Liplus-Project/dipper_ai/internal/ddns" "github.com/Liplus-Project/dipper_ai/internal/ip" "github.com/Liplus-Project/dipper_ai/internal/state" - "github.com/Liplus-Project/dipper_ai/internal/timegate" ) // Package-level function variables — overridable in tests. @@ -30,29 +28,16 @@ var ( // - Per-domain IP cache: each provider entry independently tracks the last // IP it was sent. Only entries whose cached IP differs from the current // IP are updated ("changed domains only"). -// - MyDNS keepalive: when UPDATE_TIME has elapsed, all MyDNS entries are -// force-updated regardless of IP change. MyDNS registrations expire if -// not refreshed periodically. +// - Timing is controlled by the daemon's internal ticker (DDNS_TIME interval). +// Update() itself has no rate-limiting gate — the caller is responsible. +// - Keepalive is handled separately by Keepalive() on its own ticker. // - Cloudflare: no keepalive — API records persist until explicitly changed. -// - DDNS_TIME: outer rate-limit gate. When set (>0), the entire check+update -// process runs at most once per DDNS_TIME minutes (except when bypassed by -// a caller such as check.go which deletes gate_ddns first). func Update(cfg *config.Config) error { st, err := state.New(cfg.StateDir) if err != nil { return err } - // --- DDNS_TIME outer gate (rate limiter) --- - // 0 = disabled (run every invocation); N = run at most every N minutes. - var ddnsGate *timegate.Gate - if cfg.DDNSTime > 0 { - ddnsGate = timegate.New(cfg.StateDir, "ddns", time.Duration(cfg.DDNSTime)*time.Minute) - if !ddnsGate.ShouldRun() { - return nil - } - } - // --- Fetch current external IP --- wantV4 := cfg.IPv4 && cfg.IPv4DDNS wantV6 := cfg.IPv6 && cfg.IPv6DDNS @@ -73,17 +58,10 @@ func Update(cfg *config.Config) error { return fetched.ErrIPv6 } - // --- UPDATE_TIME gate: MyDNS keepalive --- - // When elapsed, all MyDNS entries are force-updated regardless of IP change. - // Cloudflare is excluded — its records persist without periodic refresh. - updateGate := timegate.New(cfg.StateDir, "update", time.Duration(cfg.UpdateTime)*time.Minute) - forceSync := updateGate.ShouldRun() - var updateErr error var successLines []string anyUpdate := false - anyIPChange := false // at least one domain updated due to IP change - anyKeepAlive := false // at least one MyDNS domain updated due to keepalive + anyIPChange := false // at least one domain updated due to IP change // --- MyDNS per-entry updates --- // Each entry is updated independently based on its own per-domain cache. @@ -97,8 +75,7 @@ func Update(cfg *config.Config) error { if wantV4 && entry.IPv4 && fetched.IPv4 != "" { cached, _ := st.ReadDomainCache(entryKey, "ipv4") - ipDiffers := fetched.IPv4 != cached - if ipDiffers || forceSync { + if fetched.IPv4 != cached { r := mydnsUpdateIPv4(dnsEntry, cfg.MyDNSIPv4URL) if r.Err != nil { _ = st.WriteDDNSResult(entryKey+"_ipv4", "fail:"+r.Err.Error()) @@ -110,19 +87,14 @@ func Update(cfg *config.Config) error { _ = st.WriteDDNSResult(entryKey+"_ipv4", "ok") successLines = append(successLines, fmt.Sprintf(" mydns[%d] %s ipv4: ok", i, entry.Domain)) anyUpdate = true - if ipDiffers { - anyIPChange = true - } else { - anyKeepAlive = true - } + anyIPChange = true } } } if wantV6 && entry.IPv6 && fetched.IPv6 != "" { cached, _ := st.ReadDomainCache(entryKey, "ipv6") - ipDiffers := fetched.IPv6 != cached - if ipDiffers || forceSync { + if fetched.IPv6 != cached { r := mydnsUpdateIPv6(dnsEntry, cfg.MyDNSIPv6URL) if r.Err != nil { _ = st.WriteDDNSResult(entryKey+"_ipv6", "fail:"+r.Err.Error()) @@ -134,11 +106,7 @@ func Update(cfg *config.Config) error { _ = st.WriteDDNSResult(entryKey+"_ipv6", "ok") successLines = append(successLines, fmt.Sprintf(" mydns[%d] %s ipv6: ok", i, entry.Domain)) anyUpdate = true - if ipDiffers { - anyIPChange = true - } else { - anyKeepAlive = true - } + anyIPChange = true } } } @@ -210,23 +178,11 @@ func Update(cfg *config.Config) error { } } - // Touch gates after processing. - if ddnsGate != nil { - _ = ddnsGate.Touch() - } - if forceSync { - _ = updateGate.Touch() - } - // --- Email notification --- - if cfg.EmailAddr != "" && len(successLines) > 0 { - wantMail := (anyIPChange && cfg.EmailChkDDNS) || (anyKeepAlive && cfg.EmailUpDDNS) - if wantMail { - // Use anyIPChange as the "reason" flag for the mail body. - if mailErr := sendUpdateNotification(cfg, fetched, anyIPChange, successLines); mailErr != nil { - _ = st.AppendError(fmt.Sprintf("update_mail_failed: %v", mailErr)) - fmt.Fprintf(os.Stderr, "dipper_ai update: mail notification failed: %v\n", mailErr) - } + if cfg.EmailAddr != "" && anyIPChange && cfg.EmailChkDDNS { + if mailErr := sendUpdateNotification(cfg, fetched, successLines); mailErr != nil { + _ = st.AppendError(fmt.Sprintf("update_mail_failed: %v", mailErr)) + fmt.Fprintf(os.Stderr, "dipper_ai update: mail notification failed: %v\n", mailErr) } } @@ -234,12 +190,7 @@ func Update(cfg *config.Config) error { } // sendUpdateNotification composes and sends an IP-update notification email. -func sendUpdateNotification(cfg *config.Config, fetched *ip.Result, ipChanged bool, successLines []string) error { - reason := "DDNS keepalive" - if ipChanged { - reason = "IP changed" - } - +func sendUpdateNotification(cfg *config.Config, fetched *ip.Result, successLines []string) error { var ipLines []string if fetched.IPv4 != "" { ipLines = append(ipLines, "IPv4: "+fetched.IPv4) @@ -249,9 +200,8 @@ func sendUpdateNotification(cfg *config.Config, fetched *ip.Result, ipChanged bo } subject := "dipper_ai: IP updated" - body := fmt.Sprintf("%s\n\nReason: %s\n\nUpdated providers:\n%s\n", + body := fmt.Sprintf("%s\n\nReason: IP changed\n\nUpdated providers:\n%s\n", strings.Join(ipLines, "\n"), - reason, strings.Join(successLines, "\n"), ) diff --git a/internal/mode/update_test.go b/internal/mode/update_test.go index f12b845..b26b93c 100644 --- a/internal/mode/update_test.go +++ b/internal/mode/update_test.go @@ -2,7 +2,6 @@ package mode import ( "errors" - "os" "strings" "testing" @@ -70,11 +69,9 @@ func captureMyDNSCalls(t *testing.T) *[]string { func baseCfg(t *testing.T) *config.Config { t.Helper() return &config.Config{ - StateDir: t.TempDir(), - IPv4: true, - IPv4DDNS: true, - UpdateTime: 1, - DDNSTime: 0, // keepalive disabled by default + StateDir: t.TempDir(), + IPv4: true, + IPv4DDNS: true, } } @@ -177,18 +174,16 @@ func TestUpdate_IPv6FetchFail_IPv4Proceeds(t *testing.T) { } } -// TestUpdate_Keepalive verifies that when UPDATE_TIME elapses, all MyDNS entries -// are force-updated even when the IP has not changed (MyDNS keepalive). -func TestUpdate_Keepalive(t *testing.T) { +// TestUpdate_NoRepeatWhenIPUnchanged verifies that Update() does not call DDNS +// providers on subsequent runs when the IP has not changed. +func TestUpdate_NoRepeatWhenIPUnchanged(t *testing.T) { cfg := baseCfg(t) - // DDNSTime=0: no rate limit. UpdateTime=1: keepalive gate interval. - cfg.UpdateTime = 1 cfg.MyDNS = []config.MyDNSEntry{{ID: "id0", Pass: "pass0", Domain: "home.example.com", IPv4: true}} overrideFetch(t, fakeFetch("1.2.3.4", "")) calls := captureMyDNSCalls(t) - // First run — per-domain cache empty (0.0.0.0) → IP changed → DDNS called, gate_update touched. + // First run — cache empty → IP changed → DDNS called. if err := Update(cfg); err != nil { t.Fatalf("first run: %v", err) } @@ -197,31 +192,19 @@ func TestUpdate_Keepalive(t *testing.T) { t.Fatal("expected DDNS call on first run") } - // Second run — same IP, gate_update still active → no forceSync → skip. + // Second run — same IP → no update. if err := Update(cfg); err != nil { t.Fatalf("second run: %v", err) } if len(*calls) != after1 { - t.Errorf("expected no DDNS call when IP unchanged and UPDATE_TIME gate active") - } - - // Remove gate_update to simulate UPDATE_TIME elapsed. - _ = os.Remove(cfg.StateDir + "/gate_update") - - // Third run — same IP, but UPDATE_TIME elapsed → forceSync → MyDNS must fire. - if err := Update(cfg); err != nil { - t.Fatalf("keepalive run: %v", err) - } - if len(*calls) <= after1 { - t.Errorf("expected keepalive DDNS call when UPDATE_TIME elapsed (IP unchanged)") + t.Errorf("expected no DDNS call when IP unchanged; got %d extra call(s)", len(*calls)-after1) } } -// TestUpdate_CloudflareNoKeepalive verifies that Cloudflare entries are NOT -// updated on UPDATE_TIME keepalive — only on IP change. -func TestUpdate_CloudflareNoKeepalive(t *testing.T) { +// TestUpdate_CloudflareNoRepeatUpdate verifies that Cloudflare entries are NOT +// updated on subsequent runs when the IP has not changed. +func TestUpdate_CloudflareNoRepeatUpdate(t *testing.T) { cfg := baseCfg(t) - cfg.UpdateTime = 1 cfCalls := &[]string{} origCF := cloudflareUpdate cloudflareUpdate = func(e ddns.CloudflareEntry, ip, recType, url string) ddns.ProviderResult { @@ -245,15 +228,12 @@ func TestUpdate_CloudflareNoKeepalive(t *testing.T) { t.Fatal("expected CF call on first run (IP changed)") } - // Remove gate_update to simulate UPDATE_TIME elapsed. - _ = os.Remove(cfg.StateDir + "/gate_update") - - // Second run: same IP, UPDATE_TIME elapsed → forceSync → MyDNS fires but CF must NOT. + // Second run: same IP → CF must NOT be called again. if err := Update(cfg); err != nil { - t.Fatalf("keepalive run: %v", err) + t.Fatalf("second run: %v", err) } if len(*cfCalls) != after1 { - t.Errorf("Cloudflare must NOT be updated on keepalive (forceSync); got %d extra calls", len(*cfCalls)-after1) + t.Errorf("Cloudflare must NOT be updated when IP unchanged; got %d extra call(s)", len(*cfCalls)-after1) } } @@ -360,45 +340,6 @@ func TestUpdate_Mail_IPChanged(t *testing.T) { } } -// TestUpdate_Mail_Keepalive verifies that EMAIL_UP_DDNS triggers mail on -// keepalive updates (IP unchanged, DDNS_TIME elapsed), not on IP-change runs. -func TestUpdate_Mail_Keepalive(t *testing.T) { - cfg := baseCfg(t) - cfg.UpdateTime = 1 // keepalive gate interval - cfg.EmailAddr = "test@example.com" - cfg.EmailUpDDNS = true // notify on keepalive only - cfg.EmailChkDDNS = false - cfg.MyDNS = []config.MyDNSEntry{{ID: "id0", Pass: "pass0", Domain: "home.example.com", IPv4: true}} - - overrideFetch(t, fakeFetch("1.2.3.4", "")) - captureMyDNSCalls(t) - sent := captureMailCalls(t) - - // First run: IP changed (0.0.0.0 → 1.2.3.4) → EMAIL_CHK_DDNS=false → no mail. - if err := Update(cfg); err != nil { - t.Fatalf("first run: %v", err) - } - if len(*sent) != 0 { - t.Errorf("no mail expected on IP-change run when EMAIL_CHK_DDNS=false, got %d", len(*sent)) - } - - // Simulate UPDATE_TIME elapsed (keepalive). - _ = os.Remove(cfg.StateDir + "/gate_update") - - // Second run: same IP, UPDATE_TIME elapsed → forceSync → EMAIL_UP_DDNS=true → mail expected. - if err := Update(cfg); err != nil { - t.Fatalf("keepalive run: %v", err) - } - if len(*sent) == 0 { - t.Error("expected mail on keepalive run when EMAIL_UP_DDNS=true") - } else { - mail := (*sent)[0] - if !strings.Contains(mail, "DDNS keepalive") { - t.Errorf("mail body should contain reason 'DDNS keepalive', got: %s", mail) - } - } -} - // TestUpdate_Mail_BothOff verifies that no mail is sent when both // EMAIL_CHK_DDNS and EMAIL_UP_DDNS are false. func TestUpdate_Mail_BothOff(t *testing.T) { diff --git a/scripts/install.sh b/scripts/install.sh index 7c76f47..ae4e11c 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -34,57 +34,20 @@ if [[ ! -f "$CONF_DIR/user.conf" ]]; then fi fi -# --- Determine DDNS_TIME for systemd timer interval --- -# Read DDNS_TIME from user.conf and convert to minutes. -# Supported formats: 5m, 2h, 1d, 30s, or plain integer (minutes). -# Priority: /etc/dipper_ai/user.conf > ./user.conf > default (5 min). -# DDNS_TIME=0 means "no rate-limit gate" — fall back to 5-minute default. - -parse_duration_min() { - local v="$1" - if [[ "$v" =~ ^([0-9]+)d$ ]]; then echo $(( ${BASH_REMATCH[1]} * 1440 )) - elif [[ "$v" =~ ^([0-9]+)h$ ]]; then echo $(( ${BASH_REMATCH[1]} * 60 )) - elif [[ "$v" =~ ^([0-9]+)m$ ]]; then echo "${BASH_REMATCH[1]}" - elif [[ "$v" =~ ^([0-9]+)s$ ]]; then - local sec="${BASH_REMATCH[1]}" - echo $(( (sec + 59) / 60 )) # round up to nearest minute - elif [[ "$v" =~ ^[0-9]+$ ]]; then echo "$v" # plain integer = minutes - else echo "5" # unrecognised → default - fi -} - -DDNS_TIME_MIN=5 -for conf_candidate in "$CONF_DIR/user.conf" "./user.conf"; do - if [[ -f "$conf_candidate" ]]; then - v=$(grep -E '^DDNS_TIME=' "$conf_candidate" 2>/dev/null | tail -1 | cut -d= -f2 | sed 's/[[:space:]#].*//') - parsed=$(parse_duration_min "$v") - if [[ "$parsed" =~ ^[1-9][0-9]*$ ]]; then - DDNS_TIME_MIN="$parsed" - fi - break +# Remove old timer-based units if present (upgrade from pre-daemon versions). +for old_unit in dipper_ai.timer dipper_ai-keepalive.service dipper_ai-keepalive.timer; do + if [[ -f "$SYSTEMD_DIR/$old_unit" ]]; then + systemctl disable --now "$old_unit" 2>/dev/null || true + rm -f "$SYSTEMD_DIR/$old_unit" + echo " -> Removed legacy unit: $old_unit" fi done -echo "Installing systemd units (DDNS_TIME=${DDNS_TIME_MIN}min)..." +echo "Installing systemd service..." install -m 0644 ./systemd/dipper_ai.service "$SYSTEMD_DIR/" -# Generate the timer with the interval derived from DDNS_TIME. -# OnBootSec=2min gives the system a short warm-up period after boot. -cat > "$SYSTEMD_DIR/dipper_ai.timer" <