Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/dipper_ai/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import (
const usage = `Usage: dipper_ai <command>

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
`

Expand All @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions internal/mode/daemon.go
Original file line number Diff line number Diff line change
@@ -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
}
}
125 changes: 125 additions & 0 deletions internal/mode/keepalive.go
Original file line number Diff line number Diff line change
@@ -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
}
109 changes: 109 additions & 0 deletions internal/mode/keepalive_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading