diff --git a/cmd/dipper_ai/main.go b/cmd/dipper_ai/main.go index 7acbc8b..359da5a 100644 --- a/cmd/dipper_ai/main.go +++ b/cmd/dipper_ai/main.go @@ -14,6 +14,7 @@ const usage = `Usage: dipper_ai Commands: update Fetch IP, update DDNS if changed check Check current IP and DDNS status + keepalive Force-update all DDNS providers (MyDNS keepalive) err_mail Aggregate errors and send notification if threshold met ` @@ -44,6 +45,8 @@ func main() { 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/keepalive.go b/internal/mode/keepalive.go new file mode 100644 index 0000000..b0944b6 --- /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: +// - Triggered by its own systemd timer (dipper_ai-keepalive.timer) at +// UPDATE_TIME interval, fully independent of the check/update timer. +// - 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..b8d4d9a --- /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. + 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..9f724a4 100644 --- a/internal/mode/update.go +++ b/internal/mode/update.go @@ -30,9 +30,8 @@ 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. +// - Keepalive is NOT handled here — it is a separate `keepalive` command +// triggered by its own systemd timer (dipper_ai-keepalive.timer). // - 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 @@ -73,17 +72,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 +89,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 +101,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 +120,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 } } } @@ -214,19 +196,12 @@ func Update(cfg *config.Config) error { 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, true, 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) } } diff --git a/internal/mode/update_test.go b/internal/mode/update_test.go index f12b845..fecfab0 100644 --- a/internal/mode/update_test.go +++ b/internal/mode/update_test.go @@ -2,7 +2,6 @@ package mode import ( "errors" - "os" "strings" "testing" @@ -177,51 +176,10 @@ 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_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) - // 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. - if err := Update(cfg); err != nil { - t.Fatalf("first run: %v", err) - } - after1 := len(*calls) - if after1 == 0 { - t.Fatal("expected DDNS call on first run") - } - - // Second run — same IP, gate_update still active → no forceSync → skip. - 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)") - } -} - -// TestUpdate_CloudflareNoKeepalive verifies that Cloudflare entries are NOT -// updated on UPDATE_TIME keepalive — only on IP change. -func TestUpdate_CloudflareNoKeepalive(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 +203,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 → no cache diff → 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 calls", len(*cfCalls)-after1) } } @@ -360,45 +315,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..c4f3583 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -34,11 +34,10 @@ 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. +# --- Parse time config values from user.conf --- # 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. +# Priority: /etc/dipper_ai/user.conf > ./user.conf > default. +# Returns 0 for unrecognised values. parse_duration_min() { local v="$1" @@ -49,26 +48,44 @@ parse_duration_min() { 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 + else echo "0" # unrecognised → 0 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" +read_conf_value() { + local key="$1" + local val="" + for conf_candidate in "$CONF_DIR/user.conf" "./user.conf"; do + if [[ -f "$conf_candidate" ]]; then + val=$(grep -E "^${key}=" "$conf_candidate" 2>/dev/null | tail -1 | cut -d= -f2 | sed 's/[[:space:]#].*//') + break fi - break - fi -done + done + echo "$val" +} -echo "Installing systemd units (DDNS_TIME=${DDNS_TIME_MIN}min)..." +# --- DDNS_TIME: check/update timer interval (default 5 min) --- +DDNS_TIME_MIN=5 +_v=$(read_conf_value "DDNS_TIME") +_parsed=$(parse_duration_min "$_v") +if [[ "$_parsed" =~ ^[1-9][0-9]*$ ]]; then + DDNS_TIME_MIN="$_parsed" +fi + +# --- UPDATE_TIME: keepalive timer interval (default 1440 min = 1 day) --- +# UPDATE_TIME=0 means keepalive is disabled — no keepalive timer is installed. +UPDATE_TIME_MIN=1440 +_v=$(read_conf_value "UPDATE_TIME") +_parsed=$(parse_duration_min "$_v") +if [[ "$_parsed" =~ ^[0-9]+$ ]]; then + UPDATE_TIME_MIN="$_parsed" +fi + +echo "Installing systemd units (DDNS_TIME=${DDNS_TIME_MIN}min, UPDATE_TIME=${UPDATE_TIME_MIN}min)..." install -m 0644 ./systemd/dipper_ai.service "$SYSTEMD_DIR/" +install -m 0644 ./systemd/dipper_ai-keepalive.service "$SYSTEMD_DIR/" -# Generate the timer with the interval derived from DDNS_TIME. +# --- Check/update timer (DDNS_TIME interval) --- # OnBootSec=2min gives the system a short warm-up period after boot. cat > "$SYSTEMD_DIR/dipper_ai.timer" < "$SYSTEMD_DIR/dipper_ai-keepalive.timer" </dev/null || true +fi + systemctl daemon-reload systemctl enable --now dipper_ai.timer echo "dipper_ai installed successfully." -echo "Status: $(systemctl is-active dipper_ai.timer)" +echo "Check timer: $(systemctl is-active dipper_ai.timer)" +if [[ "$UPDATE_TIME_MIN" =~ ^[1-9][0-9]*$ ]]; then + echo "Keepalive timer: $(systemctl is-active dipper_ai-keepalive.timer)" +else + echo "Keepalive timer: disabled (UPDATE_TIME=0)" +fi diff --git a/systemd/dipper_ai-keepalive.service b/systemd/dipper_ai-keepalive.service new file mode 100644 index 0000000..7ef20db --- /dev/null +++ b/systemd/dipper_ai-keepalive.service @@ -0,0 +1,12 @@ +[Unit] +Description=dipper_ai DDNS keepalive +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/etc/dipper_ai +ExecStart=/usr/bin/dipper_ai keepalive + +[Install] +WantedBy=multi-user.target diff --git a/systemd/dipper_ai-keepalive.timer b/systemd/dipper_ai-keepalive.timer new file mode 100644 index 0000000..c9c9c6c --- /dev/null +++ b/systemd/dipper_ai-keepalive.timer @@ -0,0 +1,10 @@ +[Unit] +Description=dipper_ai DDNS keepalive timer + +[Timer] +OnBootSec=5min +OnUnitActiveSec=1440min +Unit=dipper_ai-keepalive.service + +[Install] +WantedBy=timers.target