From 63096f21b2f80732f1097f1922d8f1a656ed77bb Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 16:55:18 +0800 Subject: [PATCH 01/44] feat: add fixed_fallback policy for fixed() mode with health-aware failover Adds DialerSelectionPolicy_FixedWithFallback (fixed_fallback in config), which behaves like fixed(n) but tracks the fixed dialer's alive state: - When the fixed dialer is alive: always select it (same as fixed(n)) - When the fixed dialer is dead/backoff: fall back to min_moving_avg among all alive dialers in the group - When the fixed dialer revives (periodic health check restores it): traffic automatically returns to the fixed dialer Unlike fixed() which ignores alive state entirely, fixed_fallback() requires AliveDialerSet tracking and participates in the health check lifecycle. This gives users the ability to pin traffic to a preferred node while still having a safety net when that node goes down. Related: #1005 --- common/consts/dialer.go | 4 +++ component/outbound/dialer_group.go | 34 ++++++++++++++++++- component/outbound/dialer_selection_policy.go | 3 +- example.dae | 5 +++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/common/consts/dialer.go b/common/consts/dialer.go index cef35b4a24..2ad6991faf 100644 --- a/common/consts/dialer.go +++ b/common/consts/dialer.go @@ -26,6 +26,10 @@ const ( DialerSelectionPolicy_Random DialerSelectionPolicy = "random" // DialerSelectionPolicy_Fixed always selects the first dialer. DialerSelectionPolicy_Fixed DialerSelectionPolicy = "fixed" + // DialerSelectionPolicy_FixedWithFallback always selects the n-th dialer when alive; + // falls back to min_moving_avg among other alive dialers when dead. + // When the fixed dialer revives, traffic will automatically return to it. + DialerSelectionPolicy_FixedWithFallback DialerSelectionPolicy = "fixed_fallback" // DialerSelectionPolicy_MinAverage10Latencies selects the dialer with minimum average latency of last 10 checks. DialerSelectionPolicy_MinAverage10Latencies DialerSelectionPolicy = "min_avg10" // DialerSelectionPolicy_MinMovingAverageLatencies selects the dialer with minimum moving average latency. diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 28f1be7fae..50142adce9 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -362,6 +362,37 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou selected := preferAlternateSelectionNetworkType(g.Dialers[policy.FixedIndex], networkType) return g.Dialers[policy.FixedIndex], 0, selected, nil + case consts.DialerSelectionPolicy_FixedWithFallback: + // FixedWithFallback always prefers the configured dialer when alive. + // When the fixed dialer is backoff/dead, it falls back to + // min_moving_avg selection among all alive dialers. + // When the fixed dialer revives (periodic health check restores it), + // traffic automatically returns to it. + if policy.FixedIndex < 0 || policy.FixedIndex >= len(g.Dialers) { + return nil, 0, nil, fmt.Errorf("selected dialer index is out of range") + } + fixedDialer := g.Dialers[policy.FixedIndex] + if fixedDialer.MustGetAlive(networkType) { + selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) + return fixedDialer, 0, selected, nil + } + // Fixed dialer is not alive. Fall back to min_moving_avg. + networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ + Policy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, + }) + for i := range count { + a := state.aliveDialerSets[networkTypes[i].Index()] + if a == nil { + continue + } + d, latency := a.GetMinLatency(nil) + if d != nil { + selected := preferAlternateSelectionNetworkType(d, &networkTypes[i]) + return d, latency, selected, nil + } + } + return nil, time.Hour, nil, ErrNoAliveDialer + case consts.DialerSelectionPolicy_MinLastLatency, consts.DialerSelectionPolicy_MinAverage10Latencies, consts.DialerSelectionPolicy_MinMovingAverageLatencies: @@ -476,7 +507,8 @@ func policyNeedsAliveState(policy consts.DialerSelectionPolicy) bool { case consts.DialerSelectionPolicy_Random, consts.DialerSelectionPolicy_MinLastLatency, consts.DialerSelectionPolicy_MinAverage10Latencies, - consts.DialerSelectionPolicy_MinMovingAverageLatencies: + consts.DialerSelectionPolicy_MinMovingAverageLatencies, + consts.DialerSelectionPolicy_FixedWithFallback: return true case consts.DialerSelectionPolicy_Fixed: return false diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 667312d8b4..2acd74964c 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -35,7 +35,8 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer return &DialerSelectionPolicy{ Policy: fName, }, nil - case consts.DialerSelectionPolicy_Fixed: + case consts.DialerSelectionPolicy_Fixed, + consts.DialerSelectionPolicy_FixedWithFallback: if f.Not { return nil, fmt.Errorf("policy param does not support not operator: !%v()", f.Name) diff --git a/example.dae b/example.dae index 873ee992e6..0032047066 100644 --- a/example.dae +++ b/example.dae @@ -315,6 +315,11 @@ group { # Select the first node from the group for every connection. #policy: fixed(0) + # Select the first node when alive, fall back to min_moving_avg when dead. + # When the fixed node revives (periodic health check restores it), + # traffic automatically returns to it. + #policy: fixed_fallback(0) + # Select the node with min last latency from the group for every connection. #policy: min From aa76568ff269fae825e48dbfa8b83c86272a1b17 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 17:03:13 +0800 Subject: [PATCH 02/44] add workflow_dispatch trigger for manual build --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2592fefcfe..d1dc499247 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,7 @@ name: Build (Main) on: + workflow_dispatch: push: branches: - main From f22e01a1b25684cf5dcbc209737c0c9e19182ee2 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 17:13:28 +0800 Subject: [PATCH 03/44] fix: fixed_fallback gracefully falls back when index out of range --- component/outbound/dialer_group.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 50142adce9..6d5b65ed53 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -368,15 +368,16 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou // min_moving_avg selection among all alive dialers. // When the fixed dialer revives (periodic health check restores it), // traffic automatically returns to it. - if policy.FixedIndex < 0 || policy.FixedIndex >= len(g.Dialers) { - return nil, 0, nil, fmt.Errorf("selected dialer index is out of range") - } - fixedDialer := g.Dialers[policy.FixedIndex] - if fixedDialer.MustGetAlive(networkType) { - selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) - return fixedDialer, 0, selected, nil + var fixedAlive bool + if policy.FixedIndex >= 0 && policy.FixedIndex < len(g.Dialers) { + fixedDialer := g.Dialers[policy.FixedIndex] + if fixedDialer.MustGetAlive(networkType) { + selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) + return fixedDialer, 0, selected, nil + } + fixedAlive = false } - // Fixed dialer is not alive. Fall back to min_moving_avg. + // Fixed dialer is out of range or not alive. Fall back to min_moving_avg. networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ Policy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, }) From e0a784573383522097ef9fe483552cf4ebdc1f9a Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 17:19:25 +0800 Subject: [PATCH 04/44] fix: remove unused variable fixedAlive --- component/outbound/dialer_group.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 6d5b65ed53..35563be439 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -368,14 +368,12 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou // min_moving_avg selection among all alive dialers. // When the fixed dialer revives (periodic health check restores it), // traffic automatically returns to it. - var fixedAlive bool if policy.FixedIndex >= 0 && policy.FixedIndex < len(g.Dialers) { fixedDialer := g.Dialers[policy.FixedIndex] if fixedDialer.MustGetAlive(networkType) { selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) return fixedDialer, 0, selected, nil } - fixedAlive = false } // Fixed dialer is out of range or not alive. Fall back to min_moving_avg. networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ From d44eb314aa63fa977a4249b51242b1a67e615c56 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 17:45:58 +0800 Subject: [PATCH 05/44] feat: add timeout and retry params to fixed_fallback syntax: fixed_fallback(n, timeout_seconds, max_retries) - timeout: how long to wait between retries when node is dead (default 3s) - retries: how many retries before falling back to min_moving_avg (default 3) matching passwall2's autoswitch model: connect_timeout + retry_num --- component/outbound/dialer_group.go | 45 ++++++++++++-- component/outbound/dialer_selection_policy.go | 59 +++++++++++++++++-- example.dae | 5 +- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 35563be439..0bf2492395 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -40,6 +40,10 @@ type DialerGroup struct { resuscitateLastTime atomic.Int64 noAliveLogLastTimes [8]atomic.Int64 + // fixed_fallback retry state + fixedFallbackDeadSince atomic.Int64 + fixedFallbackRetryCount atomic.Int64 + cachedMinCheckInterval time.Duration } @@ -364,18 +368,49 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou case consts.DialerSelectionPolicy_FixedWithFallback: // FixedWithFallback always prefers the configured dialer when alive. - // When the fixed dialer is backoff/dead, it falls back to - // min_moving_avg selection among all alive dialers. - // When the fixed dialer revives (periodic health check restores it), - // traffic automatically returns to it. + // When the fixed dialer is backoff/dead, it will retry with configurable + // timeout and max retries before falling back to min_moving_avg. + // When the fixed dialer revives, traffic automatically returns to it. if policy.FixedIndex >= 0 && policy.FixedIndex < len(g.Dialers) { fixedDialer := g.Dialers[policy.FixedIndex] if fixedDialer.MustGetAlive(networkType) { + // Node is alive → reset retry state and use it + g.fixedFallbackDeadSince.Store(0) + g.fixedFallbackRetryCount.Store(0) + selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) + return fixedDialer, 0, selected, nil + } + // Fixed dialer is dead. Apply retry logic with timeout. + nowUnix := time.Now().Unix() + deadSince := g.fixedFallbackDeadSince.Load() + retries := g.fixedFallbackRetryCount.Load() + + if deadSince == 0 { + // First time detecting dead → record time + g.fixedFallbackDeadSince.Store(nowUnix) + deadSince = nowUnix + } + + elapsed := time.Duration(nowUnix-deadSince) * time.Second + if elapsed < policy.FixedFallbackTimeout { + // Still within current timeout window → keep trying fixed node + selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) + return fixedDialer, 0, selected, nil + } + + // Timeout passed → this counts as one retry + newRetries := retries + 1 + if int(newRetries) < policy.FixedFallbackRetries { + // Still have retries left → reset timer, keep trying + g.fixedFallbackRetryCount.Store(newRetries) + g.fixedFallbackDeadSince.Store(nowUnix) selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) return fixedDialer, 0, selected, nil } + // Max retries reached → fallback (but keep state so we don't + // reset until the node revives) } - // Fixed dialer is out of range or not alive. Fall back to min_moving_avg. + // Fixed dialer is out of range or retries exhausted. Fall back to min_moving_avg. networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ Policy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, }) diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 2acd74964c..3c83725a76 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -8,14 +8,17 @@ package outbound import ( "fmt" "strconv" + "time" "github.com/daeuniverse/dae/common/consts" "github.com/daeuniverse/dae/config" ) type DialerSelectionPolicy struct { - Policy consts.DialerSelectionPolicy - FixedIndex int + Policy consts.DialerSelectionPolicy + FixedIndex int + FixedFallbackTimeout time.Duration // 节点超时时间 + FixedFallbackRetries int // 超时重试次数 } func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *DialerSelectionPolicy, err error) { @@ -35,8 +38,7 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer return &DialerSelectionPolicy{ Policy: fName, }, nil - case consts.DialerSelectionPolicy_Fixed, - consts.DialerSelectionPolicy_FixedWithFallback: + case consts.DialerSelectionPolicy_Fixed: if f.Not { return nil, fmt.Errorf("policy param does not support not operator: !%v()", f.Name) @@ -54,6 +56,55 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer FixedIndex: index, }, nil + case consts.DialerSelectionPolicy_FixedWithFallback: + + if f.Not { + return nil, fmt.Errorf("policy param does not support not operator: !%v()", f.Name) + } + if len(f.Params) < 1 || len(f.Params) > 3 { + return nil, fmt.Errorf(`invalid "%v" param format: expected 1-3 params, got %v`, f.Name, len(f.Params)) + } + // Parse index (required, first param) + if f.Params[0].Key != "" { + return nil, fmt.Errorf(`invalid "%v" param format: first param must be index (no key)`, f.Name) + } + index, err := strconv.Atoi(f.Params[0].Val) + if err != nil { + return nil, fmt.Errorf(`invalid "%v" param format: %w`, f.Name, err) + } + // Parse timeout (optional, second param, in seconds) + timeout := 3 * time.Second // default + if len(f.Params) >= 2 { + if f.Params[1].Key != "" { + return nil, fmt.Errorf(`invalid "%v" param format: second param must be timeout seconds (no key)`, f.Name) + } + timeoutSec, err := strconv.ParseFloat(f.Params[1].Val, 64) + if err != nil { + return nil, fmt.Errorf(`invalid "%v" param format: timeout must be a number: %w`, f.Name, err) + } + timeout = time.Duration(timeoutSec * float64(time.Second)) + } + // Parse retries (optional, third param) + retries := 3 // default + if len(f.Params) >= 3 { + if f.Params[2].Key != "" { + return nil, fmt.Errorf(`invalid "%v" param format: third param must be retry count (no key)`, f.Name) + } + retries, err = strconv.Atoi(f.Params[2].Val) + if err != nil { + return nil, fmt.Errorf(`invalid "%v" param format: retries must be an integer: %w`, f.Name, err) + } + if retries < 1 { + return nil, fmt.Errorf(`invalid "%v" param format: retries must be >= 1`, f.Name) + } + } + return &DialerSelectionPolicy{ + Policy: consts.DialerSelectionPolicy_FixedWithFallback, + FixedIndex: index, + FixedFallbackTimeout: timeout, + FixedFallbackRetries: retries, + }, nil + default: return nil, fmt.Errorf("unexpected policy: %v", f.Name) } diff --git a/example.dae b/example.dae index 0032047066..c5425cb6c0 100644 --- a/example.dae +++ b/example.dae @@ -315,9 +315,12 @@ group { # Select the first node from the group for every connection. #policy: fixed(0) - # Select the first node when alive, fall back to min_moving_avg when dead. + # Select the n-th node when alive, fall back to min_moving_avg when dead. # When the fixed node revives (periodic health check restores it), # traffic automatically returns to it. + # Optional params: timeout (default 3s), retries (default 3): + # fixed_fallback(n, timeout_seconds, max_retries) + # Example: fixed_fallback(0, 5, 3) — wait 5s between retries, give up after 3 retries #policy: fixed_fallback(0) # Select the node with min last latency from the group for every connection. From f51a8e8e25dbf5d0167ba400bf33f0435ff3355f Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 17:54:22 +0800 Subject: [PATCH 06/44] fixed_fallback: add timeout unit suffix (ms/s/m) and rate-limited logging - parseDurationWithUnit: supports 500ms, 5s, 2m, or bare number (seconds) - logFixedFallback: state-deduplicated, rate-limited logging with log levels - WARN: fixed dialer dead, starting retry - INFO: retry N/M step (with retry count) - WARN: retries exhausted, falling back to min_moving_avg - INFO: fixed dialer recovered, traffic returned - 10s minimum interval between logs; same mark=no duplicate --- component/outbound/dialer_group.go | 69 ++++++++++++++++++- component/outbound/dialer_selection_policy.go | 55 +++++++++++++-- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 0bf2492395..b34337b3a5 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -44,6 +44,10 @@ type DialerGroup struct { fixedFallbackDeadSince atomic.Int64 fixedFallbackRetryCount atomic.Int64 + // fixed_fallback log rate limit + fixedFallbackLastLogMark atomic.Int64 // 0=alive, 1=dead_detected, 2+=retry_step, -1=fallen_back + fixedFallbackLastLogTime atomic.Int64 + cachedMinCheckInterval time.Duration } @@ -293,6 +297,47 @@ func (g *DialerGroup) logNoAlive( }).Warn("no alive dialer for selection (rate-limited)") } +// logFixedFallback logs fixed_fallback events in a rate-limited, state-aware manner. +// mark is used to deduplicate: same mark → skipped, different mark → logged. +// Special marks: 0=alive/recovery, 1=dead_detected, 2+N=retry_step, -1=fallen_back. +func (g *DialerGroup) logFixedFallback(level logrus.Level, mark int64, dialerName, format string, args ...interface{}) { + const logInterval = 10 * time.Second + + // Deduplicate by mark: skip if we already logged this state + if g.fixedFallbackLastLogMark.Load() == mark { + return + } + + // Rate limit: max one log per interval + if !g.tryDoRateLimitedAction(&g.fixedFallbackLastLogTime, logInterval) { + return + } + + g.fixedFallbackLastLogMark.Store(mark) + + if g.log == nil { + return + } + if !g.log.IsLevelEnabled(level) { + return + } + + entry := g.log.WithFields(logrus.Fields{ + "group": g.Name, + "dialer": dialerName, + }) + switch level { + case logrus.InfoLevel: + entry.Infof(format, args...) + case logrus.WarnLevel: + entry.Warnf(format, args...) + case logrus.ErrorLevel: + entry.Errorf(format, args...) + default: + entry.Infof(format, args...) + } +} + // Select is a backward-compatible wrapper for SelectWithExclusion. func (g *DialerGroup) Select(networkType *dialer.NetworkType, strictIpVersion bool) (d *dialer.Dialer, latency time.Duration, err error) { d, latency, _, err = g.SelectWithExclusionResult(networkType, strictIpVersion, nil) @@ -373,10 +418,20 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou // When the fixed dialer revives, traffic automatically returns to it. if policy.FixedIndex >= 0 && policy.FixedIndex < len(g.Dialers) { fixedDialer := g.Dialers[policy.FixedIndex] + fixedName := "" + if p := fixedDialer.Property(); p != nil { + fixedName = p.Name + } + if fixedDialer.MustGetAlive(networkType) { // Node is alive → reset retry state and use it - g.fixedFallbackDeadSince.Store(0) + wasDead := g.fixedFallbackDeadSince.Swap(0) != 0 g.fixedFallbackRetryCount.Store(0) + if wasDead { + // Recovery log (rate-limited) + g.logFixedFallback(logrus.InfoLevel, 0, fixedName, + "fixed dialer recovered, traffic returned") + } selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) return fixedDialer, 0, selected, nil } @@ -388,7 +443,12 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou if deadSince == 0 { // First time detecting dead → record time g.fixedFallbackDeadSince.Store(nowUnix) - deadSince = nowUnix + g.logFixedFallback(logrus.WarnLevel, 1, fixedName, + "fixed dialer dead, starting retry (timeout=%v, max_retries=%d)", + policy.FixedFallbackTimeout, policy.FixedFallbackRetries) + // Keep using fixed node (will fail, but user explicitly configured it) + selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) + return fixedDialer, 0, selected, nil } elapsed := time.Duration(nowUnix-deadSince) * time.Second @@ -404,11 +464,16 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou // Still have retries left → reset timer, keep trying g.fixedFallbackRetryCount.Store(newRetries) g.fixedFallbackDeadSince.Store(nowUnix) + g.logFixedFallback(logrus.InfoLevel, 2+newRetries, fixedName, + "fixed dialer retry %d/%d", newRetries, policy.FixedFallbackRetries) selected := preferAlternateSelectionNetworkType(fixedDialer, networkType) return fixedDialer, 0, selected, nil } // Max retries reached → fallback (but keep state so we don't // reset until the node revives) + g.logFixedFallback(logrus.WarnLevel, -1, fixedName, + "fixed dialer retries exhausted (%d/%d), falling back to min_moving_avg", + policy.FixedFallbackRetries, policy.FixedFallbackRetries) } // Fixed dialer is out of range or retries exhausted. Fall back to min_moving_avg. networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 3c83725a76..2f7a92ce30 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -8,6 +8,7 @@ package outbound import ( "fmt" "strconv" + "strings" "time" "github.com/daeuniverse/dae/common/consts" @@ -72,17 +73,16 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer if err != nil { return nil, fmt.Errorf(`invalid "%v" param format: %w`, f.Name, err) } - // Parse timeout (optional, second param, in seconds) + // Parse timeout (optional, second param, with unit suffix: ms/s/m) timeout := 3 * time.Second // default if len(f.Params) >= 2 { if f.Params[1].Key != "" { - return nil, fmt.Errorf(`invalid "%v" param format: second param must be timeout seconds (no key)`, f.Name) + return nil, fmt.Errorf(`invalid "%v" param format: second param must be timeout (no key)`, f.Name) } - timeoutSec, err := strconv.ParseFloat(f.Params[1].Val, 64) + timeout, err = parseDurationWithUnit(f.Params[1].Val) if err != nil { - return nil, fmt.Errorf(`invalid "%v" param format: timeout must be a number: %w`, f.Name, err) + return nil, fmt.Errorf(`invalid "%v" param format: %w`, f.Name, err) } - timeout = time.Duration(timeoutSec * float64(time.Second)) } // Parse retries (optional, third param) retries := 3 // default @@ -109,3 +109,48 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer return nil, fmt.Errorf("unexpected policy: %v", f.Name) } } + +// parseDurationWithUnit parses a duration string with optional unit suffix. +// Supported: "ms" (milliseconds), "s" (seconds), "m" (minutes). +// No suffix defaults to seconds for backward compatibility. +// Examples: "500ms", "5s", "2m", "10". +func parseDurationWithUnit(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty duration string") + } + + // Check "ms" first (must precede "s" check) + if strings.HasSuffix(s, "ms") { + val, err := strconv.ParseFloat(strings.TrimSuffix(s, "ms"), 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + return time.Duration(val * float64(time.Millisecond)), nil + } + + // "s" suffix + if strings.HasSuffix(s, "s") { + val, err := strconv.ParseFloat(strings.TrimSuffix(s, "s"), 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + return time.Duration(val * float64(time.Second)), nil + } + + // "m" suffix + if strings.HasSuffix(s, "m") { + val, err := strconv.ParseFloat(strings.TrimSuffix(s, "m"), 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + return time.Duration(val * float64(time.Minute)), nil + } + + // No suffix: treat as seconds (backward compatible) + val, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + return time.Duration(val * float64(time.Second)), nil +} From 179c6eecd6c3b45a95c725dd35a5840f78c6b281 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:08:57 +0800 Subject: [PATCH 07/44] fixed_fallback: add dynamic fallback policy support - Add FallbackPolicy field to DialerSelectionPolicy struct - Parse optional 4th param: fixed_fallback(n, timeout, retries, policy) Supported fallback policies: random, min_moving_avg, min_last_latency, min_avg10 Default: min_moving_avg (backward compatible) - Update _select() to use policy.FallbackPolicy instead of hardcoded min_moving_avg - Add parsePolicyName() helper to map policy name strings - Update example.dae documentation - Timeout param now supports unit suffix: 500ms, 5s, 2m (from previous commit) --- component/outbound/dialer_group.go | 10 ++--- component/outbound/dialer_selection_policy.go | 37 +++++++++++++++++-- example.dae | 10 +++-- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index b34337b3a5..33b57aa6f1 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -414,7 +414,7 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou case consts.DialerSelectionPolicy_FixedWithFallback: // FixedWithFallback always prefers the configured dialer when alive. // When the fixed dialer is backoff/dead, it will retry with configurable - // timeout and max retries before falling back to min_moving_avg. + // timeout and max retries before falling back to the configured fallback policy. // When the fixed dialer revives, traffic automatically returns to it. if policy.FixedIndex >= 0 && policy.FixedIndex < len(g.Dialers) { fixedDialer := g.Dialers[policy.FixedIndex] @@ -472,12 +472,12 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou // Max retries reached → fallback (but keep state so we don't // reset until the node revives) g.logFixedFallback(logrus.WarnLevel, -1, fixedName, - "fixed dialer retries exhausted (%d/%d), falling back to min_moving_avg", - policy.FixedFallbackRetries, policy.FixedFallbackRetries) + "fixed dialer retries exhausted (%d/%d), falling back to %v", + policy.FixedFallbackRetries, policy.FixedFallbackRetries, policy.FallbackPolicy) } - // Fixed dialer is out of range or retries exhausted. Fall back to min_moving_avg. + // Fixed dialer is out of range or retries exhausted. Fall back to configured policy. networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ - Policy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, + Policy: policy.FallbackPolicy, }) for i := range count { a := state.aliveDialerSets[networkTypes[i].Index()] diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 2f7a92ce30..93ad25bb9e 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -18,8 +18,9 @@ import ( type DialerSelectionPolicy struct { Policy consts.DialerSelectionPolicy FixedIndex int - FixedFallbackTimeout time.Duration // 节点超时时间 - FixedFallbackRetries int // 超时重试次数 + FixedFallbackTimeout time.Duration // 节点超时时间 + FixedFallbackRetries int // 超时重试次数 + FallbackPolicy consts.DialerSelectionPolicy // 重试耗尽后的回退策略,默认 min_moving_avg } func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *DialerSelectionPolicy, err error) { @@ -98,11 +99,24 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer return nil, fmt.Errorf(`invalid "%v" param format: retries must be >= 1`, f.Name) } } + // Parse fallback policy (optional, fourth param) + fallbackPolicy := consts.DialerSelectionPolicy_MinMovingAverageLatencies // default + if len(f.Params) >= 4 { + if f.Params[3].Key != "" { + return nil, fmt.Errorf(`invalid "%v" param format: fourth param must be fallback policy name (no key)`, f.Name) + } + fp, err := parsePolicyName(f.Params[3].Val) + if err != nil { + return nil, fmt.Errorf(`invalid "%v" param format: fallback policy: %w`, f.Name, err) + } + fallbackPolicy = fp + } return &DialerSelectionPolicy{ Policy: consts.DialerSelectionPolicy_FixedWithFallback, FixedIndex: index, FixedFallbackTimeout: timeout, FixedFallbackRetries: retries, + FallbackPolicy: fallbackPolicy, }, nil default: @@ -110,7 +124,24 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer } } -// parseDurationWithUnit parses a duration string with optional unit suffix. +// parsePolicyName maps a policy name string to the corresponding DialerSelectionPolicy constant. +// Supported fallback policies: random, min_moving_avg, min_last_latency, min_avg10. +// Fixed and fixed_fallback are not supported as fallback policies (would cause infinite recursion). +func parsePolicyName(s string) (consts.DialerSelectionPolicy, error) { + s = strings.TrimSpace(s) + switch s { + case "random": + return consts.DialerSelectionPolicy_Random, nil + case "min_moving_avg": + return consts.DialerSelectionPolicy_MinMovingAverageLatencies, nil + case "min_last_latency": + return consts.DialerSelectionPolicy_MinLastLatency, nil + case "min_avg10": + return consts.DialerSelectionPolicy_MinAverage10Latencies, nil + default: + return 0, fmt.Errorf("unsupported fallback policy %q (supported: random, min_moving_avg, min_last_latency, min_avg10)", s) + } +} // Supported: "ms" (milliseconds), "s" (seconds), "m" (minutes). // No suffix defaults to seconds for backward compatibility. // Examples: "500ms", "5s", "2m", "10". diff --git a/example.dae b/example.dae index c5425cb6c0..c6c884dd03 100644 --- a/example.dae +++ b/example.dae @@ -315,12 +315,14 @@ group { # Select the first node from the group for every connection. #policy: fixed(0) - # Select the n-th node when alive, fall back to min_moving_avg when dead. + # Select the n-th node when alive, fall back to a configurable policy when dead. # When the fixed node revives (periodic health check restores it), # traffic automatically returns to it. - # Optional params: timeout (default 3s), retries (default 3): - # fixed_fallback(n, timeout_seconds, max_retries) - # Example: fixed_fallback(0, 5, 3) — wait 5s between retries, give up after 3 retries + # Optional params: timeout (default 3s), retries (default 3), fallback_policy (default min_moving_avg): + # fixed_fallback(n, timeout, max_retries, fallback_policy) + # timeout supports unit suffix: 500ms, 5s, 2m, or bare number (seconds) + # fallback_policy: random, min_moving_avg (default), min_last_latency, min_avg10 + # Example: fixed_fallback(0, 5s, 3, random) — fallback to random after retries exhausted #policy: fixed_fallback(0) # Select the node with min last latency from the group for every connection. From 0098234908e303c230560d42254771891d974b9b Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:12:06 +0800 Subject: [PATCH 08/44] fix: allow up to 4 params in fixed_fallback (was incorrectly limited to 3) --- component/outbound/dialer_selection_policy.go | 2 +- dae-linux-x86_64.zip | 345 ++++++++++++++++++ 2 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 dae-linux-x86_64.zip diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 93ad25bb9e..43bc14925b 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -63,7 +63,7 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer if f.Not { return nil, fmt.Errorf("policy param does not support not operator: !%v()", f.Name) } - if len(f.Params) < 1 || len(f.Params) > 3 { + if len(f.Params) < 1 || len(f.Params) > 4 { return nil, fmt.Errorf(`invalid "%v" param format: expected 1-3 params, got %v`, f.Name, len(f.Params)) } // Parse index (required, first param) diff --git a/dae-linux-x86_64.zip b/dae-linux-x86_64.zip new file mode 100644 index 0000000000..954be3805a --- /dev/null +++ b/dae-linux-x86_64.zip @@ -0,0 +1,345 @@ +{ + "total_count": 17, + "artifacts": [ + { + "id": 7609703317, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAzMzE3", + "name": "dae-linux-mips32.zip", + "size_in_bytes": 16713467, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609703317", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609703317/zip", + "expired": false, + "digest": "sha256:3e68e83c58149e63f51036349dcd0a5fc2f8caa0c03fde26381105195420b351", + "created_at": "2026-06-13T09:56:12Z", + "updated_at": "2026-06-13T09:56:12Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609703027, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAzMDI3", + "name": "dae-linux-arm64.zip", + "size_in_bytes": 16860785, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609703027", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609703027/zip", + "expired": false, + "digest": "sha256:ae54af13136e16541472754f06e4e58191f5579629262782e9f7ad721a982dc1", + "created_at": "2026-06-13T09:56:09Z", + "updated_at": "2026-06-13T09:56:09Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609702588, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAyNTg4", + "name": "dae-linux-powerpc64.zip", + "size_in_bytes": 16966795, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702588", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702588/zip", + "expired": false, + "digest": "sha256:a398df6608631c99f002f99655197a000fa9e1fcd0fb821ca30db0ff82a4cc8d", + "created_at": "2026-06-13T09:56:05Z", + "updated_at": "2026-06-13T09:56:05Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609702337, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAyMzM3", + "name": "dae-linux-riscv64.zip", + "size_in_bytes": 17196636, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702337", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702337/zip", + "expired": false, + "digest": "sha256:a69b0c916af1be63bbe4ec44b77238041f7e38641ee65cce47974d6aaf76d2bd", + "created_at": "2026-06-13T09:56:03Z", + "updated_at": "2026-06-13T09:56:03Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609702230, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAyMjMw", + "name": "dae-linux-x86_64_v2_sse.zip", + "size_in_bytes": 18055439, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702230", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702230/zip", + "expired": false, + "digest": "sha256:3f632e0ee42f61a50243001ac84d42c680fb17f96fae9f8ad57503278e10fa2b", + "created_at": "2026-06-13T09:56:02Z", + "updated_at": "2026-06-13T09:56:02Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609702110, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAyMTEw", + "name": "dae-linux-mips32le.zip", + "size_in_bytes": 16612565, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702110", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702110/zip", + "expired": false, + "digest": "sha256:835a2800390a43c14c2ace67c17c5d6ab756921b0122dae475cbdf76f7036945", + "created_at": "2026-06-13T09:56:01Z", + "updated_at": "2026-06-13T09:56:01Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609702031, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAyMDMx", + "name": "dae-linux-x86_32.zip", + "size_in_bytes": 17468443, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702031", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609702031/zip", + "expired": false, + "digest": "sha256:f49a7cc7c8ff6faf714747db333606642b88f7e9b0549b7311796e7c924ac3df", + "created_at": "2026-06-13T09:56:00Z", + "updated_at": "2026-06-13T09:56:00Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609701985, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAxOTg1", + "name": "dae-linux-mips64.zip", + "size_in_bytes": 16396403, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701985", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701985/zip", + "expired": false, + "digest": "sha256:7bd4f7724caf87c1de92e9c4a02ad823ef082df99c4bb60f9d55e5942510c810", + "created_at": "2026-06-13T09:56:00Z", + "updated_at": "2026-06-13T09:56:00Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609701920, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAxOTIw", + "name": "dae-linux-powerpc64le.zip", + "size_in_bytes": 16914723, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701920", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701920/zip", + "expired": false, + "digest": "sha256:61529af9d68e88784ca97a28ca842bf7cd2991c328dc65d566a058b1104cd0c1", + "created_at": "2026-06-13T09:55:59Z", + "updated_at": "2026-06-13T09:55:59Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609701693, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAxNjkz", + "name": "dae-linux-x86_64_v3_avx2.zip", + "size_in_bytes": 18043862, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701693", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701693/zip", + "expired": false, + "digest": "sha256:973f0e1fba5a884d9cc4ed976275c0df687753052d2bbfb5f845bc63c3ec57c1", + "created_at": "2026-06-13T09:55:57Z", + "updated_at": "2026-06-13T09:55:57Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609701530, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAxNTMw", + "name": "dae-linux-armv7.zip", + "size_in_bytes": 17245478, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701530", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701530/zip", + "expired": false, + "digest": "sha256:cd2bf7e7def58161a480b43d12792dc6be6ff9bd827e511d4c94e99aa94fd107", + "created_at": "2026-06-13T09:55:55Z", + "updated_at": "2026-06-13T09:55:55Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609701502, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAxNTAy", + "name": "dae-linux-mips64le.zip", + "size_in_bytes": 16267548, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701502", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701502/zip", + "expired": false, + "digest": "sha256:d436e37be77ef80ec80cc6b943c28bb5e21edac4ffe719e3b3685770e5bd4163", + "created_at": "2026-06-13T09:55:55Z", + "updated_at": "2026-06-13T09:55:55Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609701066, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAxMDY2", + "name": "dae-linux-loongarch64.zip", + "size_in_bytes": 17467119, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701066", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701066/zip", + "expired": false, + "digest": "sha256:3481ade51ead19a6ffdcd4c55c364d228f9e7c1c3691f578a03a7281937cac23", + "created_at": "2026-06-13T09:55:51Z", + "updated_at": "2026-06-13T09:55:51Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609701043, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAxMDQz", + "name": "dae-linux-s390x.zip", + "size_in_bytes": 17590781, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701043", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609701043/zip", + "expired": false, + "digest": "sha256:09bdcc191943b9a54ee7c2da7d8723d471785506ae7d2494313ca133ab213428", + "created_at": "2026-06-13T09:55:51Z", + "updated_at": "2026-06-13T09:55:51Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609700160, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5NzAwMTYw", + "name": "dae-linux-armv6.zip", + "size_in_bytes": 17263369, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609700160", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609700160/zip", + "expired": false, + "digest": "sha256:e8063145848469aeae9cb5dca93a00dd02a133518b3a57267afbe49f71e17314", + "created_at": "2026-06-13T09:55:42Z", + "updated_at": "2026-06-13T09:55:42Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609699075, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5Njk5MDc1", + "name": "dae-linux-armv5.zip", + "size_in_bytes": 17277034, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609699075", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609699075/zip", + "expired": false, + "digest": "sha256:ed0e86130b8536730ac7bfd6c304aa82a6ffff5886f859b37aecf934619ee20a", + "created_at": "2026-06-13T09:55:32Z", + "updated_at": "2026-06-13T09:55:32Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + }, + { + "id": 7609698047, + "node_id": "MDg6QXJ0aWZhY3Q3NjA5Njk4MDQ3", + "name": "dae-linux-x86_64.zip", + "size_in_bytes": 18057540, + "url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609698047", + "archive_download_url": "https://api.github.com/repos/itoywh/dae/actions/artifacts/7609698047/zip", + "expired": false, + "digest": "sha256:2548865ed0c5c2ec29f9f3ad231df52fb9c2018e07de6edd316a13dbc95aed2a", + "created_at": "2026-06-13T09:55:23Z", + "updated_at": "2026-06-13T09:55:23Z", + "expires_at": "2026-09-11T09:54:32Z", + "workflow_run": { + "id": 27463459944, + "repository_id": 1268200500, + "head_repository_id": 1268200500, + "head_branch": "main", + "head_sha": "758ac8d37cd0f2863dca3b6f3b4c1c5822947b4e" + } + } + ] +} From 93833734fe42fb628f072e8950c346f45b47542a Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:15:35 +0800 Subject: [PATCH 09/44] fix: type cast in parsePolicyName default return --- component/outbound/dialer_selection_policy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 43bc14925b..43f69a41ba 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -139,7 +139,7 @@ func parsePolicyName(s string) (consts.DialerSelectionPolicy, error) { case "min_avg10": return consts.DialerSelectionPolicy_MinAverage10Latencies, nil default: - return 0, fmt.Errorf("unsupported fallback policy %q (supported: random, min_moving_avg, min_last_latency, min_avg10)", s) + return consts.DialerSelectionPolicy(0), fmt.Errorf("unsupported fallback policy %q (supported: random, min_moving_avg, min_last_latency, min_avg10)", s) } } // Supported: "ms" (milliseconds), "s" (seconds), "m" (minutes). From cfd05455648302a54ad8400739e2fa0b9bb549af Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:40:04 +0800 Subject: [PATCH 10/44] fixed_fallback: use FallbackPolicy for AliveDialerSet to enable latency tracking Previously, AliveDialerSet for fixed_fallback groups was created with FixedWithFallback policy, which caused NotifyLatencyChange to skip latency tracking (FixedWithFallback case only updates alive status). This meant: - sortingLatency was always 0 for all dialers - GetMinLatency() returned arbitrary first alive dialer - check_tolerance had no effect (no latency data to compare) Fix: when policy is FixedWithFallback, create the AliveDialerSet with FallbackPolicy instead. The fixed-node path (fixed dialer alive) bypasses the set entirely, so this change only affects the fallback path where proper latency-based selection actually matters. With this fix, fallback selection now correctly: - Tracks moving average / last latency / avg10 per dialer - Respects check_tolerance when comparing dialers - Returns the truly optimal node instead of first alive --- component/outbound/dialer_group.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 33b57aa6f1..440e0d171f 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -560,8 +560,16 @@ func (g *DialerGroup) buildSelectionState(policy DialerSelectionPolicy, setAlive for i, nt := range specs { networkType := *nt + // For FixedWithFallback, build the AliveDialerSet using FallbackPolicy + // so that latency data is properly tracked for the fallback path. + // The main (fixed) path bypasses the set entirely, so using FallbackPolicy + // here has no adverse effect on fixed-node selection. + setPolicy := policy.Policy + if policy.Policy == consts.DialerSelectionPolicy_FixedWithFallback { + setPolicy = policy.FallbackPolicy + } set := dialer.NewAliveDialerSet( - g.log, g.Name, &networkType, g.checkTolerance, policy.Policy, + g.log, g.Name, &networkType, g.checkTolerance, setPolicy, g.Dialers, g.dialersAnnotations, func(networkType *dialer.NetworkType) func(alive bool) { return func(alive bool) { g.aliveChangeCallback(alive, networkType, false) } From f554498fa86b7faab75a054b911464740129ad12 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:41:47 +0800 Subject: [PATCH 11/44] fixed_fallback: handle random fallback policy correctly Previously, the fallback path always called GetMinLatency() regardless of the fallback policy, which is wrong for random (should pick a random alive dialer, not the min-latency one). Add a branch in the fallback path: - Random: collect all alive dialers, pick one at random - min_*: use GetMinLatency() as before (now with proper latency tracking thanks to the FallbackPolicy AliveDialerSet fix) --- component/outbound/dialer_group.go | 41 ++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 440e0d171f..5da3e8cffa 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -17,6 +17,7 @@ import ( "github.com/daeuniverse/dae/component/outbound/dialer" _ "github.com/daeuniverse/outbound/dialer" "github.com/daeuniverse/outbound/netproxy" + "github.com/daeuniverse/outbound/pkg/fastrand" "github.com/sirupsen/logrus" ) @@ -476,19 +477,37 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou policy.FixedFallbackRetries, policy.FixedFallbackRetries, policy.FallbackPolicy) } // Fixed dialer is out of range or retries exhausted. Fall back to configured policy. - networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ - Policy: policy.FallbackPolicy, - }) - for i := range count { - a := state.aliveDialerSets[networkTypes[i].Index()] - if a == nil { - continue + switch policy.FallbackPolicy { + case consts.DialerSelectionPolicy_Random: + // Random: collect all alive dialers and pick one at random. + var aliveList []*dialer.Dialer + for _, d := range g.Dialers { + if d.MustGetAlive(networkType) { + aliveList = append(aliveList, d) + } } - d, latency := a.GetMinLatency(nil) - if d != nil { - selected := preferAlternateSelectionNetworkType(d, &networkTypes[i]) - return d, latency, selected, nil + if len(aliveList) > 0 { + chosen := aliveList[int(fastrand.Int63n(int64(len(aliveList))))] + selected := preferAlternateSelectionNetworkType(chosen, networkType) + return chosen, 0, selected, nil } + default: + // min_* policies: use AliveDialerSet for latency-based selection. + networkTypes, count := g.selectionNetworkTypes(networkType, DialerSelectionPolicy{ + Policy: policy.FallbackPolicy, + }) + for i := range count { + a := state.aliveDialerSets[networkTypes[i].Index()] + if a == nil { + continue + } + d, latency := a.GetMinLatency(nil) + if d != nil { + selected := preferAlternateSelectionNetworkType(d, &networkTypes[i]) + return d, latency, selected, nil + } + } + } } return nil, time.Hour, nil, ErrNoAliveDialer From 0d330037fe0c2b725bc178824081d6fe56cc8b38 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:46:32 +0800 Subject: [PATCH 12/44] fix: syntax error in FixedWithFallback fallback path The inner switch block was missing its closing brace for the case FixedWithFallback block, causing 'unexpected keyword case' at the next case statement. --- component/outbound/dialer_group.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 5da3e8cffa..c29c9e25bf 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -508,8 +508,8 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou } } } - } return nil, time.Hour, nil, ErrNoAliveDialer + } case consts.DialerSelectionPolicy_MinLastLatency, consts.DialerSelectionPolicy_MinAverage10Latencies, From 7082c3c99454ff553e1c42aab05152f8548c547c Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:49:22 +0800 Subject: [PATCH 13/44] fix: brace indentation in FixedWithFallback block The closing brace for case FixedWithFallback and the preceding return statement had incorrect indentation, causing the compiler to misinterpret the block structure. --- component/outbound/dialer_group.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index c29c9e25bf..0c2854fc14 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -509,7 +509,7 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou } } return nil, time.Hour, nil, ErrNoAliveDialer - } + } case consts.DialerSelectionPolicy_MinLastLatency, consts.DialerSelectionPolicy_MinAverage10Latencies, From 770b083be87875f48f47592364022771eb2fd754 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 18:51:48 +0800 Subject: [PATCH 14/44] fix: remove spurious closing brace in FixedWithFallback case In Go, case blocks don't need closing braces - they are delimited by the next case or the outer switch's closing brace. The extra } was closing the entire switch prematurely. --- component/outbound/dialer_group.go | 1 - 1 file changed, 1 deletion(-) diff --git a/component/outbound/dialer_group.go b/component/outbound/dialer_group.go index 0c2854fc14..ae71b7f940 100644 --- a/component/outbound/dialer_group.go +++ b/component/outbound/dialer_group.go @@ -509,7 +509,6 @@ func (g *DialerGroup) _select(networkType *dialer.NetworkType, state *dialerGrou } } return nil, time.Hour, nil, ErrNoAliveDialer - } case consts.DialerSelectionPolicy_MinLastLatency, consts.DialerSelectionPolicy_MinAverage10Latencies, From e7a6f03c6faed5df6e79917003a4f22f8f6ad662 Mon Sep 17 00:00:00 2001 From: itoywh <46450501+itoywh@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:14:55 +0800 Subject: [PATCH 15/44] docs: add fork enhancements to README --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index ff43e7efbf..09bc87a586 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,37 @@ lastcommit

+> **This is an enhanced fork** with disaster-recovery capabilities for the `fixed` dialer mode. +> See [Releases](https://github.com/itoywh/dae/releases) for pre-built binaries. + +## Fork Enhancements + +### 1. Disaster-Recovery Fixed Fallback + +The original `fixed` dialer mode has a critical weakness: if the fixed node goes down, traffic stops entirely. +This fork introduces `fixed_fallback` with full disaster-recovery semantics: + +```ini +dial_mode: fixed_fallback(1, 5s, 3, min_moving_avg) +``` + +- **Automatic fallback**: When the fixed node fails (after configurable timeout + retries), traffic automatically switches to the best alive node +- **Auto switchback**: When the fixed node recovers (detected by dae's connectivity checker), traffic returns automatically +- **Configurable timeout**: Supports `ms`/`s`/`m` unit suffixes, e.g. `5s`, `500ms`, `2m` +- **Configurable fallback policy**: `min_moving_avg` (default), `random`, `min_last_latency` +- **Rate-limited logging**: WARN/INFO level logs for fallback events with 10s rate limiting + +### 2. Improved Log Format + +Log timestamps now use human-readable format: `[2026-01-02 15:04:05] INFO ...` + +### Upstream PRs + +- [PR #1009](https://github.com/daeuniverse/dae/pull/1009) — fixed_fallback disaster-recovery enhancement +- [PR #1010](https://github.com/daeuniverse/dae/pull/1010) — log timestamp format improvement + +--- + **_dae_**, means goose, is a high-performance transparent proxy solution. To enhance traffic split performance as much as possible, dae employs the transparent proxy and traffic split suite within the Linux kernel using eBPF. As a result, dae can enable direct traffic to bypass the proxy application's forwarding, facilitating genuine direct traffic passage. Through this remarkable feat, there is minimal performance loss and negligible additional resource consumption for direct traffic. From b4e114b7049a6fb3a5553733837c19facc673fbe Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 19:37:57 +0800 Subject: [PATCH 16/44] docs: add comprehensive fixed_fallback parameter reference and usage guide --- README.md | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 09bc87a586..08a48ee540 100644 --- a/README.md +++ b/README.md @@ -15,24 +15,148 @@ ## Fork Enhancements -### 1. Disaster-Recovery Fixed Fallback +### 1. fixed_fallback — Disaster-Recovery Dialer Strategy -The original `fixed` dialer mode has a critical weakness: if the fixed node goes down, traffic stops entirely. -This fork introduces `fixed_fallback` with full disaster-recovery semantics: +The original `fixed` dialer mode has a critical weakness: **if the fixed node goes down, traffic stops entirely**. There is no failover mechanism — it's a single point of failure. + +This fork introduces `fixed_fallback`, transforming `fixed` from a fragile standalone mode into a **production-grade high-availability strategy** with full disaster-recovery semantics. + +--- + +#### Quick Start + +```ini +# In your dae config (e.g., /etc/dae/config.dae): +[group] +my_group { + dial_mode: fixed_fallback(1, 5s, 3) +} + +[global] +check_tolerance: 60ms +``` + +This means: prefer the 2nd node in the group (index 1, 0-based). If it fails after 3 connection attempts with 5 seconds timeout each → automatically switch to the best available node. When the fixed node recovers → automatically switch back. The 60ms tolerance prevents flapping. + +--- + +#### Parameter Reference + +``` +fixed_fallback(, , [, ]) +``` + +| # | Parameter | Required | Description | Example | +|---|-----------|----------|-------------|---------| +| 1 | **`index`** | ✅ | 0-based index of the fixed dialer in the group's node list. The first node is `0`, second is `1`, etc. Must point to a valid node — if out of range, falls through to fallback pool. | `1` → use the 2nd node | +| 2 | **`timeout`** | ✅ | Connection timeout per attempt. Supports unit suffixes: `ms` (milliseconds), `s` (seconds), `m` (minutes). Without suffix, treated as seconds (backward compatible). | `5s`, `500ms`, `2m`, `10` (10s) | +| 3 | **`retries`** | ✅ | Maximum connection retries before declaring the fixed node dead and triggering fallback. After all retries are exhausted, a WARN-level log is emitted. | `3` → retry 3 times | +| 4 | **`fallback_policy`** | ❌ | Fallback node selection strategy when the fixed node is down. If omitted, defaults to `min_moving_avg`. | See table below | + +##### Fallback Strategy Options + +| Policy | Behavior | Best For | +|--------|----------|----------| +| `min_moving_avg` ⭐ *(default)* | Selects the node with the lowest moving-average latency. Works with `check_tolerance` to prevent unnecessary switches. | General use — latency-sensitive traffic | +| `min_last_latency` | Selects the node with the lowest most-recent measured latency. | Environments with rapidly changing network conditions | +| `random` | Randomly picks an alive node from the fallback pool. | Load balancing — when you want to distribute traffic across the fallback pool | + +##### How `check_tolerance` Works with fixed_fallback + +The `check_tolerance` setting (under `[global]`) works alongside the fallback policy to prevent node flapping. When the fixed node recovers and its latency is within `check_tolerance` of the current fallback node's latency, dae will **not** switch back immediately — avoiding the "bounce" effect where traffic oscillates between nodes. ```ini -dial_mode: fixed_fallback(1, 5s, 3, min_moving_avg) +[global] +check_tolerance: 60ms # Only switch back if fixed node is ≥60ms better than fallback ``` -- **Automatic fallback**: When the fixed node fails (after configurable timeout + retries), traffic automatically switches to the best alive node -- **Auto switchback**: When the fixed node recovers (detected by dae's connectivity checker), traffic returns automatically -- **Configurable timeout**: Supports `ms`/`s`/`m` unit suffixes, e.g. `5s`, `500ms`, `2m` -- **Configurable fallback policy**: `min_moving_avg` (default), `random`, `min_last_latency` -- **Rate-limited logging**: WARN/INFO level logs for fallback events with 10s rate limiting +--- + +#### How It Works + +``` +┌─ Normal operation ─────────────────────────────────────────┐ +│ │ +│ fixed_fallback(1, 5s, 3, min_moving_avg) │ +│ │ +│ 1. Try fixed node (index 1 = 2nd node) │ +│ ├─ Alive? → Use it. Done. ✅ │ +│ └─ Down/Timeout? → Retry (up to 3 times) │ +│ │ +│ 2. All 3 retries exhausted? │ +│ └─ WARN log: "fixed dialer retries exhausted (3/3)" │ +│ → Fall back to min_moving_avg among alive nodes │ +│ → INFO log: "falling back to " │ +│ │ +│ 3. Connectivity Checker probes fixed node periodically │ +│ └─ Fixed node recovered? │ +│ ├─ Latency within check_tolerance? → Stay on fallback│ +│ └─ Latency significantly better? → Switch back 🔄 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +#### Configuration Examples + +**Example A: Simple failover** — prefer node `s4`, fall back to any alive node when unavailable: +```ini +[group] +my_group { + nodes: s4, s2, s5 + dial_mode: fixed_fallback(0, 5s, 3) +} +``` + +**Example B: with random fallback** — distribute fallback traffic across all backup nodes: +```ini +[group] +my_group { + nodes: s4, s2, s5 + dial_mode: fixed_fallback(0, 3s, 2, random) +} +``` + +**Example C: with check_tolerance** — prevent flapping when latencies are close: +```ini +[global] +check_tolerance: 80ms + +[group] +my_group { + nodes: s_hk, s_jp, s_sg + dial_mode: fixed_fallback(0, 5s, 3) # min_moving_avg (default) +} +``` + +**Example D: Aggressive timeout** — fail fast, retry only once: +```ini +dial_mode: fixed_fallback(0, 500ms, 1, min_last_latency) +``` + +--- + +#### Compatibility + +- **Backward compatible**: existing `fixed_fallback(1, 5s, 3)` configs work without changes +- **Time unit backward compatibility**: `fixed_fallback(1, 5, 3)` (no suffix) still works — treated as seconds +- **Works with all existing dialer strategies**: `fixed_fallback` is an additional strategy, all other strategies (`random`, `min_moving_avg`, etc.) continue to work as-is + +--- ### 2. Improved Log Format -Log timestamps now use human-readable format: `[2026-01-02 15:04:05] INFO ...` +Log timestamps now use human-readable format with `ForceFormatting` enabled: + +``` +Before: INFO selected dialer: s4 ... +After: [2026-06-13 15:04:05] INFO selected dialer: s4 ... +``` + +The format `[YYYY-MM-DD HH:MM:SS]` prefix makes logs easier to read and parse with standard tools (e.g., `grep`, `awk`, log viewers). + +--- ### Upstream PRs From 2943e82879703cb84c102503872b809b3123f292 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 19:41:21 +0800 Subject: [PATCH 17/44] fix: s/dial_mode/policy/ in README examples --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 08a48ee540..fc2e94e973 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ This fork introduces `fixed_fallback`, transforming `fixed` from a fragile stand # In your dae config (e.g., /etc/dae/config.dae): [group] my_group { - dial_mode: fixed_fallback(1, 5s, 3) + policy: fixed_fallback(1, 5s, 3) } [global] @@ -105,7 +105,7 @@ check_tolerance: 60ms # Only switch back if fixed node is ≥60ms better than [group] my_group { nodes: s4, s2, s5 - dial_mode: fixed_fallback(0, 5s, 3) + policy: fixed_fallback(0, 5s, 3) } ``` @@ -114,7 +114,7 @@ my_group { [group] my_group { nodes: s4, s2, s5 - dial_mode: fixed_fallback(0, 3s, 2, random) + policy: fixed_fallback(0, 3s, 2, random) } ``` @@ -126,13 +126,13 @@ check_tolerance: 80ms [group] my_group { nodes: s_hk, s_jp, s_sg - dial_mode: fixed_fallback(0, 5s, 3) # min_moving_avg (default) + policy: fixed_fallback(0, 5s, 3) # min_moving_avg (default) } ``` **Example D: Aggressive timeout** — fail fast, retry only once: ```ini -dial_mode: fixed_fallback(0, 500ms, 1, min_last_latency) +policy: fixed_fallback(0, 500ms, 1, min_last_latency) ``` --- From d09b48e060e076c3802151a611773a0627d87f7b Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 19:42:29 +0800 Subject: [PATCH 18/44] docs: add Chinese README (README_zh.md) with full parameter reference --- README_zh.md | 217 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 README_zh.md diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000000..3f3de46dd3 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,217 @@ +# dae + + + +

+ Build + License + version + + lastcommit +

+ +> **这是一个增强版 Fork**,为 `fixed` 拨号模式增加了完整的灾备能力。 +> 预编译二进制请见 [Releases](https://github.com/itoywh/dae/releases)。 + +## Fork 增强内容 + +### 1. fixed_fallback — 灾备拨号策略 + +原生 `fixed` 模式有一个致命缺陷:**如果固定节点挂了,流量就直接断了**。没有故障转移机制 — 这是一个单点故障。 + +本 Fork 引入 `fixed_fallback`,将 `fixed` 从脆弱的单点模式升级为 **生产级高可用策略**,具备完整的灾备语义。 + +--- + +#### 快速上手 + +```ini +# 在 dae 配置文件中 (如 /etc/dae/config.dae): +[group] +my_group { + policy: fixed_fallback(1, 5s, 3) +} + +[global] +check_tolerance: 60ms +``` + +含义:优先使用组内第 2 个节点(索引 1,从 0 开始)。如果连接失败,每次超时 5 秒、最多重试 3 次 → 全部失败后自动切到最佳存活节点。固定节点恢复后自动切回。60ms 容差防止来回抖动。 + +--- + +#### 参数详解 + +``` +fixed_fallback(<索引>, <超时>, <重试次数>[, ]) +``` + +| # | 参数 | 必填 | 说明 | 示例 | +|---|------|------|------|------| +| 1 | **`索引`** | ✅ | 固定节点在 group 节点列表中的 0-based 索引。第一个节点是 `0`,第二个是 `1`,以此类推。超出范围则直接走 fallback 池。 | `1` → 用第 2 个节点 | +| 2 | **`超时`** | ✅ | 每次连接的超时时间。支持单位后缀:`ms`(毫秒)、`s`(秒)、`m`(分钟)。不带后缀视为秒(向后兼容)。 | `5s`、`500ms`、`2m`、`10`(即 10 秒) | +| 3 | **`重试次数`** | ✅ | 宣告固定节点死亡并触发 fallback 之前的最大重试次数。耗尽后会输出 WARN 级别日志。 | `3` → 重试 3 次 | +| 4 | **`fallback策略`** | ❌ | 固定节点宕机后选择备用节点的策略。省略则默认使用 `min_moving_avg`。 | 见下表面 | + +##### Fallback 策略选项 + +| 策略 | 行为 | 适用场景 | +|------|------|----------| +| `min_moving_avg` ⭐ *(默认)* | 选择移动平均延迟最低的节点。配合 `check_tolerance` 防抖动。 | 通用场景 — 延迟敏感流量 | +| `min_last_latency` | 选择最近一次实测延迟最低的节点。 | 网络条件快速变化的环境 | +| `random` | 从存活的 fallback 节点池中随机选一个。 | 负载均衡 — 希望分散流量到备用节点池 | + +##### check_tolerance 与 fixed_fallback 的配合 + +`check_tolerance` 配置项(位于 `[global]` 下)与 fallback 策略协同工作,防止节点来回切换(抖动)。当固定节点恢复后,如果其延迟与当前 fallback 节点相比差距在 `check_tolerance` 范围内,dae 不会立即切回 — 避免了流量在两个节点间振荡。 + +```ini +[global] +check_tolerance: 60ms # 仅当固定节点延迟比 fallback 节点好 ≥60ms 时才切回 +``` + +--- + +#### 工作原理 + +``` +┌─ 正常工作流程 ─────────────────────────────────────────────┐ +│ │ +│ fixed_fallback(1, 5s, 3, min_moving_avg) │ +│ │ +│ 1. 尝试连接固定节点(索引 1 = 第 2 个节点) │ +│ ├─ 存活?→ 使用它。完成 ✅ │ +│ └─ 宕机/超时?→ 重试(最多 3 次) │ +│ │ +│ 2. 3 次重试全部耗尽? │ +│ └─ WARN 日志: "fixed dialer retries exhausted (3/3)" │ +│ → 按 min_moving_avg 从存活节点中选择最优 │ +│ → INFO 日志: "falling back to " │ +│ │ +│ 3. Connectivity Checker 周期性探测固定节点 │ +│ └─ 固定节点恢复了? │ +│ ├─ 延迟在 check_tolerance 范围内?→ 保持 fallback │ +│ └─ 延迟明显更好?→ 自动切回 🔄 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +#### 配置示例 + +**示例 A:简单灾备** — 优先用 s4,不可用时切到其他存活节点: +```ini +[group] +my_group { + nodes: s4, s2, s5 + policy: fixed_fallback(0, 5s, 3) +} +``` + +**示例 B:随机 fallback(负载均衡)** — 把 fallback 流量均匀分散到各备用节点: +```ini +[group] +my_group { + nodes: s4, s2, s5 + policy: fixed_fallback(0, 3s, 2, random) +} +``` + +**示例 C:配合 check_tolerance** — 防止延迟接近时来回切换: +```ini +[global] +check_tolerance: 80ms + +[group] +my_group { + nodes: s_hk, s_jp, s_sg + policy: fixed_fallback(0, 5s, 3) # min_moving_avg(默认) +} +``` + +**示例 D:激进超时** — 快失败,只重试一次: +```ini +policy: fixed_fallback(0, 500ms, 1, min_last_latency) +``` + +--- + +#### 兼容性 + +- **向后兼容**:现有的 `fixed_fallback(1, 5s, 3)` 配置无需修改即可继续使用 +- **超时单位向后兼容**:`fixed_fallback(1, 5, 3)`(不带后缀)仍然可用 — 视为秒 +- **与所有现有策略共存**:`fixed_fallback` 是新增策略,其他策略(`random`、`min_moving_avg` 等)保持不变 + +--- + +### 2. 日志格式优化 + +日志时间戳改为人类可读格式,启用 `ForceFormatting`: + +``` +修改前:INFO selected dialer: s4 ... +修改后:[2026-06-13 15:04:05] INFO selected dialer: s4 ... +``` + +`[年-月-日 时:分:秒]` 前缀方便阅读,也便于用标准工具(如 `grep`、`awk`、日志查看器)解析。 + +--- + +### 上游 PR + +- [PR #1009](https://github.com/daeuniverse/dae/pull/1009) — fixed_fallback 灾备增强 +- [PR #1010](https://github.com/daeuniverse/dae/pull/1010) — 日志时间戳格式优化 + +--- + +**_dae_**,意为 goose(鹅),是一款高性能透明代理解决方案。 + +为了尽可能地提升流量分流的性能,dae 在 Linux 内核中使用 eBPF 实现了透明代理和流量分流套件。因此,dae 可以让直连流量绕过代理应用的转发,实现真正的直接通过。凭借这一卓越特性,直连流量几乎没有性能损失和额外的资源消耗。 + +作为 [v2rayA](https://github.com/v2rayA/v2rayA) 的后继者,dae 放弃了 v2ray-core,以更自由地满足用户需求。 + +## 特性 + +- [x] 实现 `Real Direct` 流量分流(需开启 ipforward)以达到[高性能](https://docs.google.com/spreadsheets/d/1UaWU6nNho7edBNjNqC8dfGXLlW0-cm84MM7sH6Gp7UE/edit?usp=sharing) +- [x] 支持按本机进程名分流流量 +- [x] 支持按 LAN 内 MAC 地址分流流量 +- [x] 支持反向匹配规则分流流量 +- [x] 支持按策略自动切换节点。即自动测试独立的 TCP/UDP/IPv4/IPv6 延迟,然后根据用户定义的策略为相应流量使用最佳节点 +- [x] 支持高级 DNS 解析过程 +- [x] 支持 shadowsocks、trojan(-go) 和 socks5 的 full-cone NAT(未经测试) +- [x] 支持多种主流代理协议,详见 [proxy-protocols.md](./docs/en/proxy-protocols.md) + +## 快速开始 + +请参考 [快速开始指南](./docs/en/README.md) 立即开始使用 `dae`! + +## 注意事项 + +1. 如果你在公网(如 VPS)的同一台机器上同时部署 dae 和 shadowsocks 服务端(或任何 UDP 服务),别忘了为你的 UDP 服务端口添加 `l4proto(udp) && sport(你的服务端口) -> must_direct` 规则。因为 UDP 状态难以维护,所有出站 UDP 包都有可能被代理(取决于你的路由规则),包括发给客户端的流量。这不是我们期望的行为。`must_direct` 会让该端口的所有流量(包括 DNS 流量)直连。 +2. 如果中国大陆用户访问某些国内网站时发现首次加载时间很长,请检查是否在 DNS 路由中使用了国外 DNS 处理某些国内域名。这个问题有时很难发现。例如,`ocsp.digicert.cn` 意外地被包含在 `geosite:geolocation-!cn` 中,这会导致某些 TLS 握手耗时很长。在 DNS 路由中使用此类域名集合时请格外小心。 + +## 工作原理 + +详见 [How it works](./docs/en/how-it-works.md)。 + +## TODO + +- [ ] 自动检查 DNS 上游和源循环(上游是否也是我们的客户端)并提醒用户添加 sip 规则 +- [ ] MACv2 扩展提取 +- [ ] 日志输出到用户空间 +- [ ] 面向协议的节点特性检测(或过滤),如 full-cone(特别是 VMess 和 VLESS) +- [ ] 添加快速开始指南 +- [ ] ... + +## 贡献者 + +特别感谢所有[贡献者](https://github.com/daeuniverse/dae/graphs/contributors)。如果你想贡献代码,请参阅[说明](./docs/en/development/contribute.md)。同时建议遵循 [commit-msg-guide](./docs/en/development/commit-msg-guide.md)。 + +## 许可证 + +[AGPL-3.0 (C) daeuniverse](https://github.com/daeuniverse/dae/blob/main/LICENSE) + +## 星标历史 + +[![Stargazers over time](https://starchart.cc/daeuniverse/dae.svg)](https://starchart.cc/daeuniverse/dae) From 161c6b5b96ce79cb88d2c8b0f4063bc4f55962aa Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 19:44:44 +0800 Subject: [PATCH 19/44] =?UTF-8?q?docs:=20add=20language=20switch=20(Englis?= =?UTF-8?q?h=20|=20=E7=AE=80=E4=BD=93=E4=B8=AD=E6=96=87)=20to=20both=20REA?= =?UTF-8?q?DMEs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++++ README_zh.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index fc2e94e973..33cb899ffa 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ lastcommit

+

+ English | 简体中文 +

+ > **This is an enhanced fork** with disaster-recovery capabilities for the `fixed` dialer mode. > See [Releases](https://github.com/itoywh/dae/releases) for pre-built binaries. diff --git a/README_zh.md b/README_zh.md index 3f3de46dd3..01c2e5d8de 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,6 +10,10 @@ lastcommit

+

+ English | 简体中文 +

+ > **这是一个增强版 Fork**,为 `fixed` 拨号模式增加了完整的灾备能力。 > 预编译二进制请见 [Releases](https://github.com/itoywh/dae/releases)。 From 8b63a5c651e96d76ff6abbe49e891f2cdecd472a Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 19:45:57 +0800 Subject: [PATCH 20/44] fix: move language switch to left align --- README.md | 2 +- README_zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33cb899ffa..4bf0fb54dc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ lastcommit

-

+

English | 简体中文

diff --git a/README_zh.md b/README_zh.md index 01c2e5d8de..1b0f3c0fd9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,7 +10,7 @@ lastcommit

-

+

English | 简体中文

From 0dc137eff183e7a5b3e31b026253aae1b07b1e03 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 23:30:53 +0800 Subject: [PATCH 21/44] =?UTF-8?q?docs:=20fix=20check=5Ftolerance=20descrip?= =?UTF-8?q?tion=20=E2=80=94=20only=20governs=20fallback=20nodes,=20not=20s?= =?UTF-8?q?4=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the misleading claim that check_tolerance gates s4 switchback. In fixed_fallback, s4 recovery is a pure binary alive/dead check: once alive, traffic returns immediately, with no latency comparison. check_tolerance only applies to the AliveDialerSet internals during failover, preventing oscillation between fallback nodes (s2/s5). --- README.md | 14 ++++++++------ README_zh.md | 13 +++++++------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4bf0fb54dc..c263a374dd 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ my_group { check_tolerance: 60ms ``` -This means: prefer the 2nd node in the group (index 1, 0-based). If it fails after 3 connection attempts with 5 seconds timeout each → automatically switch to the best available node. When the fixed node recovers → automatically switch back. The 60ms tolerance prevents flapping. +This means: prefer the 2nd node in the group (index 1, 0-based). If it fails after 3 connection attempts with 5 seconds timeout each → automatically switch to the best available node. When the fixed node recovers → immediately and unconditionally switch back. The 60ms tolerance prevents flapping between fallback nodes (s2 ↔ s5) during failover. --- @@ -67,13 +67,16 @@ fixed_fallback(, , [, ]) ##### How `check_tolerance` Works with fixed_fallback -The `check_tolerance` setting (under `[global]`) works alongside the fallback policy to prevent node flapping. When the fixed node recovers and its latency is within `check_tolerance` of the current fallback node's latency, dae will **not** switch back immediately — avoiding the "bounce" effect where traffic oscillates between nodes. +The `check_tolerance` setting (under `[global]`) works alongside the fallback policy, **but only affects selection between fallback nodes** (e.g., s2 ↔ s5). It prevents unnecessary switching caused by minor latency fluctuations during failover. ```ini [global] -check_tolerance: 60ms # Only switch back if fixed node is ≥60ms better than fallback +check_tolerance: 60ms # During failover: don't switch between fallback nodes + # unless latency difference exceeds 60ms ``` +> **Important**: s4 recovery is completely independent of `check_tolerance`. Once s4 passes the health check, traffic returns **immediately and unconditionally** — no latency comparison, no tolerance threshold. `check_tolerance` governs "which backup to use," not "whether to take the primary back." + --- #### How It Works @@ -93,9 +96,8 @@ check_tolerance: 60ms # Only switch back if fixed node is ≥60ms better than │ → INFO log: "falling back to " │ │ │ │ 3. Connectivity Checker probes fixed node periodically │ -│ └─ Fixed node recovered? │ -│ ├─ Latency within check_tolerance? → Stay on fallback│ -│ └─ Latency significantly better? → Switch back 🔄 │ +│ └─ Fixed node recovered? → Switch back immediately! 🔄 │ +│ (No check_tolerance or latency comparison — alive=go) │ │ │ └─────────────────────────────────────────────────────────────┘ ``` diff --git a/README_zh.md b/README_zh.md index 1b0f3c0fd9..4a3756d1ed 100644 --- a/README_zh.md +++ b/README_zh.md @@ -40,7 +40,7 @@ my_group { check_tolerance: 60ms ``` -含义:优先使用组内第 2 个节点(索引 1,从 0 开始)。如果连接失败,每次超时 5 秒、最多重试 3 次 → 全部失败后自动切到最佳存活节点。固定节点恢复后自动切回。60ms 容差防止来回抖动。 +含义:优先使用组内第 2 个节点(索引 1,从 0 开始)。如果连接失败,每次超时 5 秒、最多重试 3 次 → 全部失败后自动切到最佳存活节点。固定节点一旦恢复立刻无条件切回。60ms 容差防止灾备期间 s2/s5 之间来回抖动。 --- @@ -67,13 +67,15 @@ fixed_fallback(<索引>, <超时>, <重试次数>[, ]) ##### check_tolerance 与 fixed_fallback 的配合 -`check_tolerance` 配置项(位于 `[global]` 下)与 fallback 策略协同工作,防止节点来回切换(抖动)。当固定节点恢复后,如果其延迟与当前 fallback 节点相比差距在 `check_tolerance` 范围内,dae 不会立即切回 — 避免了流量在两个节点间振荡。 +`check_tolerance` 配置项(位于 `[global]` 下)与 fallback 策略协同工作,**只作用于灾备期间备用节点之间的选择**,防止 s2/s5 因微小延迟波动来回切换(抖动)。 ```ini [global] -check_tolerance: 60ms # 仅当固定节点延迟比 fallback 节点好 ≥60ms 时才切回 +check_tolerance: 60ms # 灾备期间 s2↔s5 延迟差 <60ms 不切换,≥60ms 才切换 ``` +> **重要**:s4 的恢复机制完全独立于 `check_tolerance`。一旦 s4 通过存活性检测,流量会**立即无条件切回** — 不做延迟比较,不经过容差门槛。`check_tolerance` 管的是"两个备胎谁更好",不管"正主回来没"。 + --- #### 工作原理 @@ -93,9 +95,8 @@ check_tolerance: 60ms # 仅当固定节点延迟比 fallback 节点好 ≥60ms │ → INFO 日志: "falling back to " │ │ │ │ 3. Connectivity Checker 周期性探测固定节点 │ -│ └─ 固定节点恢复了? │ -│ ├─ 延迟在 check_tolerance 范围内?→ 保持 fallback │ -│ └─ 延迟明显更好?→ 自动切回 🔄 │ +│ └─ 固定节点恢复了?→ 立即无条件切回 🔄 │ +│ (不经过 check_tolerance 比较,存活即切) │ │ │ └─────────────────────────────────────────────────────────────┘ ``` From 4d599bd9d4417e81401bd5d918077feb4f86b9e7 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sat, 13 Jun 2026 23:38:24 +0800 Subject: [PATCH 22/44] =?UTF-8?q?feat:=20unify=20fallback=20policy=20names?= =?UTF-8?q?=20with=20upstream=20(min=5Flast=5Flatency=20=E2=86=92=20min)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parsePolicyName() now accepts official dae policy names only: - min (was min_last_latency) - min_moving_avg (unchanged) - min_avg10 (unchanged) - random (unchanged) All docs and examples updated to use official names. --- .gitignore | 2 +- README.md | 7 ++++--- README_zh.md | 7 ++++--- component/outbound/dialer_selection_policy.go | 7 ++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index fb7ae7477d..b82f0498be 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ node_modules/ venv CLAUDE.md AGENTS.md -.sisyphus \ No newline at end of file +.sisyphusdae-linux-* diff --git a/README.md b/README.md index c263a374dd..e8615c32e1 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ fixed_fallback(, , [, ]) | Policy | Behavior | Best For | |--------|----------|----------| -| `min_moving_avg` ⭐ *(default)* | Selects the node with the lowest moving-average latency. Works with `check_tolerance` to prevent unnecessary switches. | General use — latency-sensitive traffic | -| `min_last_latency` | Selects the node with the lowest most-recent measured latency. | Environments with rapidly changing network conditions | +| `min_moving_avg` ⭐*(default)* | Selects the node with the lowest moving-average latency. Works with `check_tolerance` to prevent unnecessary switches. | General use — latency-sensitive traffic | +| `min` | Selects the node with the lowest most-recent measured latency (official name). | Environments with rapidly changing network conditions | +| `min_avg10` | Selects the node with the lowest average latency over the last 10 checks. | Environments with high latency variance | | `random` | Randomly picks an alive node from the fallback pool. | Load balancing — when you want to distribute traffic across the fallback pool | ##### How `check_tolerance` Works with fixed_fallback @@ -138,7 +139,7 @@ my_group { **Example D: Aggressive timeout** — fail fast, retry only once: ```ini -policy: fixed_fallback(0, 500ms, 1, min_last_latency) +policy: fixed_fallback(0, 500ms, 1, min) ``` --- diff --git a/README_zh.md b/README_zh.md index 4a3756d1ed..780e222971 100644 --- a/README_zh.md +++ b/README_zh.md @@ -61,8 +61,9 @@ fixed_fallback(<索引>, <超时>, <重试次数>[, ]) | 策略 | 行为 | 适用场景 | |------|------|----------| -| `min_moving_avg` ⭐ *(默认)* | 选择移动平均延迟最低的节点。配合 `check_tolerance` 防抖动。 | 通用场景 — 延迟敏感流量 | -| `min_last_latency` | 选择最近一次实测延迟最低的节点。 | 网络条件快速变化的环境 | +| `min_moving_avg` ⭐*(默认)* | 选择移动平均延迟最低的节点。配合 `check_tolerance` 防抖动。 | 通用场景 — 延迟敏感流量 | +| `min` | 选择最近一次实测延迟最低的节点(官方名,同 `min_last_latency`)。 | 网络条件快速变化的环境 | +| `min_avg10` | 选择最近 10 次延迟平均值最低的节点。 | 延迟波动较大的环境 | | `random` | 从存活的 fallback 节点池中随机选一个。 | 负载均衡 — 希望分散流量到备用节点池 | ##### check_tolerance 与 fixed_fallback 的配合 @@ -137,7 +138,7 @@ my_group { **示例 D:激进超时** — 快失败,只重试一次: ```ini -policy: fixed_fallback(0, 500ms, 1, min_last_latency) +policy: fixed_fallback(0, 500ms, 1, min) ``` --- diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 43f69a41ba..d5213a8250 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -125,7 +125,8 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer } // parsePolicyName maps a policy name string to the corresponding DialerSelectionPolicy constant. -// Supported fallback policies: random, min_moving_avg, min_last_latency, min_avg10. +// Names must match the official dae policy names defined in common/consts/dialer.go. +// Supported: random, min_moving_avg, min (last latency), min_avg10. // Fixed and fixed_fallback are not supported as fallback policies (would cause infinite recursion). func parsePolicyName(s string) (consts.DialerSelectionPolicy, error) { s = strings.TrimSpace(s) @@ -134,12 +135,12 @@ func parsePolicyName(s string) (consts.DialerSelectionPolicy, error) { return consts.DialerSelectionPolicy_Random, nil case "min_moving_avg": return consts.DialerSelectionPolicy_MinMovingAverageLatencies, nil - case "min_last_latency": + case "min": return consts.DialerSelectionPolicy_MinLastLatency, nil case "min_avg10": return consts.DialerSelectionPolicy_MinAverage10Latencies, nil default: - return consts.DialerSelectionPolicy(0), fmt.Errorf("unsupported fallback policy %q (supported: random, min_moving_avg, min_last_latency, min_avg10)", s) + return consts.DialerSelectionPolicy(0), fmt.Errorf("unsupported fallback policy %q (supported: random, min_moving_avg, min, min_avg10)", s) } } // Supported: "ms" (milliseconds), "s" (seconds), "m" (minutes). From 9f4db21e87e13fa2065e977915263ca2185ac8dc Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 00:07:29 +0800 Subject: [PATCH 23/44] =?UTF-8?q?feat:=20dynamic=20CheckOpts=20=E2=80=94?= =?UTF-8?q?=20skip=20IPv6/UDP=20probes=20when=20not=20configured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add shouldSkipIpv6Probes(): detects if config has only explicit IPv4 addresses - Add hasUdpDnsConfig(): checks if udp_check_dns is configured - Build CheckOpts slice dynamically in aliveBackground() instead of hardcoded 4 probes - Debug log shows which probes are active on dialer start - Skip NotifyPeriodicCheckResultForType for UDP when not configured This avoids unnecessary IPv6 connectivity probes when the user's network has no IPv6 support, and skips UDP DNS probes when udp_check_dns is unset. --- .../outbound/dialer/connectivity_check.go | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index 8dcac4ac80..8042268c44 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -464,6 +464,56 @@ func getActiveDialerCount() int { return poolActiveCount } +// shouldSkipIpv6Probes returns true when both tcp_check_url and udp_check_dns +// explicitly list only IPv4 addresses (no explicit IPv6 entries). +// This avoids unnecessary IPv6 probes when the user's network doesn't support IPv6. +// Returns false (keep IPv6 probes) when: +// - Explicit IPv6 addresses are found in config +// - No explicit IPs are given (DNS resolution might return IPv6) +func shouldSkipIpv6Probes(tcpRaw, dnsRaw []string) bool { + hasIpv6 := false + hasExplicitIpv4 := false + + // Check tcp_check_url explicit IPs (element 1+) + for i := 1; i < len(tcpRaw); i++ { + addr, err := netip.ParseAddr(tcpRaw[i]) + if err != nil { + continue + } + if addr.Is6() { + hasIpv6 = true + } else { + hasExplicitIpv4 = true + } + } + + // Check udp_check_dns explicit IPs (element 1+) + for i := 1; i < len(dnsRaw); i++ { + addr, err := netip.ParseAddr(dnsRaw[i]) + if err != nil { + continue + } + if addr.Is6() { + hasIpv6 = true + } else { + hasExplicitIpv4 = true + } + } + + // If explicit IPv6 found → don't skip + if hasIpv6 { + return false + } + // If explicit IPv4 found but no explicit IPv6 → skip IPv6 probes + // If no explicit IPs at all → don't skip (DNS might resolve IPv6) + return hasExplicitIpv4 +} + +// hasUdpDnsConfig returns true if udp_check_dns is configured. +func hasUdpDnsConfig(raw []string) bool { + return len(raw) > 0 +} + func (d *Dialer) aliveBackground() { cycle := d.CheckInterval var tcpSomark uint32 @@ -563,7 +613,33 @@ func (d *Dialer) aliveBackground() { }, CheckFunc: makeDnsCheckFunc(func(o *CheckDnsOption) netip.Addr { return o.Ip6 }, &udpNetwork), } - var CheckOpts = []*CheckOption{tcp4CheckOpt, tcp6CheckOpt, udp4CheckDnsOpt, udp6CheckDnsOpt} + // Build CheckOpts dynamically based on configuration: + // - Skip IPv6 probes when config explicitly lists only IPv4 addresses + // - Skip UDP DNS probes when udp_check_dns is not configured + skipIpv6 := shouldSkipIpv6Probes(d.TcpCheckOptionRaw.Raw, d.CheckDnsOptionRaw.Raw) + useUdpDns := hasUdpDnsConfig(d.CheckDnsOptionRaw.Raw) + + var CheckOpts []*CheckOption + CheckOpts = append(CheckOpts, tcp4CheckOpt) + if !skipIpv6 { + CheckOpts = append(CheckOpts, tcp6CheckOpt) + } + if useUdpDns { + CheckOpts = append(CheckOpts, udp4CheckDnsOpt) + if !skipIpv6 { + CheckOpts = append(CheckOpts, udp6CheckDnsOpt) + } + } + + if d.Log.IsLevelEnabled(logrus.DebugLevel) { + d.Log.WithFields(logrus.Fields{ + "dialer": d.property.Name, + "tcp4": true, + "tcp6": !skipIpv6, + "udp4_dns": useUdpDns, + "udp6_dns": useUdpDns && !skipIpv6, + }).Debugln("Connectivity check probes configured") + } var unusedOnce bool checkUnused := func() bool { @@ -683,7 +759,9 @@ func (d *Dialer) aliveBackground() { // WITHOUT any successes in this cycle. This allows partially-working dual-stack // nodes (e.g. V4 OK, V6 broken) to eventually wash white their penalty. d.NotifyPeriodicCheckResult(consts.L4ProtoStr_TCP, cycleRes.tcpSuccess, cycleRes.tcpFailure && !cycleRes.tcpSuccess) - d.NotifyPeriodicCheckResultForType(udp4CheckDnsOpt.networkType, cycleRes.udpSuccess, cycleRes.udpFailure && !cycleRes.udpSuccess) + if useUdpDns { + d.NotifyPeriodicCheckResultForType(udp4CheckDnsOpt.networkType, cycleRes.udpSuccess, cycleRes.udpFailure && !cycleRes.udpSuccess) + } } // Targeted checks don't disturb the periodic timer — only full checks do. From bd3093fa49dbbe5a20dd2243ead3dd5ad5607ac2 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 00:23:19 +0800 Subject: [PATCH 24/44] fix: split IPv6 skip logic per-protocol (tcp/udp independent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace shouldSkipIpv6Probes() with shouldSkipTcp6Probes() and shouldSkipUdp6Probes() — each checks only its own config - tcp_check_url having IPv6 no longer forces udp6CheckDnsOpt - udp_check_dns defaults no longer affect tcp6 decision - IPv6 probes are skipped per-protocol when only explicit IPv4 addresses are configured in the respective check option --- .../outbound/dialer/connectivity_check.go | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index 8042268c44..fa5d90dd9b 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -464,19 +464,18 @@ func getActiveDialerCount() int { return poolActiveCount } -// shouldSkipIpv6Probes returns true when both tcp_check_url and udp_check_dns -// explicitly list only IPv4 addresses (no explicit IPv6 entries). -// This avoids unnecessary IPv6 probes when the user's network doesn't support IPv6. +// shouldSkipTcp6Probes returns true when tcp_check_url explicitly lists only IPv4 +// addresses (no explicit IPv6 entries). +// This avoids unnecessary IPv6 TCP probes when the user's network doesn't support IPv6. // Returns false (keep IPv6 probes) when: // - Explicit IPv6 addresses are found in config // - No explicit IPs are given (DNS resolution might return IPv6) -func shouldSkipIpv6Probes(tcpRaw, dnsRaw []string) bool { +func shouldSkipTcp6Probes(raw []string) bool { hasIpv6 := false hasExplicitIpv4 := false - // Check tcp_check_url explicit IPs (element 1+) - for i := 1; i < len(tcpRaw); i++ { - addr, err := netip.ParseAddr(tcpRaw[i]) + for i := 1; i < len(raw); i++ { + addr, err := netip.ParseAddr(raw[i]) if err != nil { continue } @@ -487,9 +486,20 @@ func shouldSkipIpv6Probes(tcpRaw, dnsRaw []string) bool { } } - // Check udp_check_dns explicit IPs (element 1+) - for i := 1; i < len(dnsRaw); i++ { - addr, err := netip.ParseAddr(dnsRaw[i]) + if hasIpv6 { + return false + } + return hasExplicitIpv4 +} + +// shouldSkipUdp6Probes returns true when udp_check_dns explicitly lists only IPv4 +// addresses (no explicit IPv6 entries). +func shouldSkipUdp6Probes(raw []string) bool { + hasIpv6 := false + hasExplicitIpv4 := false + + for i := 1; i < len(raw); i++ { + addr, err := netip.ParseAddr(raw[i]) if err != nil { continue } @@ -500,12 +510,9 @@ func shouldSkipIpv6Probes(tcpRaw, dnsRaw []string) bool { } } - // If explicit IPv6 found → don't skip if hasIpv6 { return false } - // If explicit IPv4 found but no explicit IPv6 → skip IPv6 probes - // If no explicit IPs at all → don't skip (DNS might resolve IPv6) return hasExplicitIpv4 } @@ -614,19 +621,21 @@ func (d *Dialer) aliveBackground() { CheckFunc: makeDnsCheckFunc(func(o *CheckDnsOption) netip.Addr { return o.Ip6 }, &udpNetwork), } // Build CheckOpts dynamically based on configuration: - // - Skip IPv6 probes when config explicitly lists only IPv4 addresses - // - Skip UDP DNS probes when udp_check_dns is not configured - skipIpv6 := shouldSkipIpv6Probes(d.TcpCheckOptionRaw.Raw, d.CheckDnsOptionRaw.Raw) + // - Skip IPv6 TCP probes when tcp_check_url only has explicit IPv4 addresses + // - Skip IPv6 UDP DNS probes when udp_check_dns only has explicit IPv4 addresses + // - Skip all UDP DNS probes when udp_check_dns is not configured + skipTcp6 := shouldSkipTcp6Probes(d.TcpCheckOptionRaw.Raw) + skipUdp6 := shouldSkipUdp6Probes(d.CheckDnsOptionRaw.Raw) useUdpDns := hasUdpDnsConfig(d.CheckDnsOptionRaw.Raw) var CheckOpts []*CheckOption CheckOpts = append(CheckOpts, tcp4CheckOpt) - if !skipIpv6 { + if !skipTcp6 { CheckOpts = append(CheckOpts, tcp6CheckOpt) } if useUdpDns { CheckOpts = append(CheckOpts, udp4CheckDnsOpt) - if !skipIpv6 { + if !skipUdp6 { CheckOpts = append(CheckOpts, udp6CheckDnsOpt) } } @@ -635,9 +644,9 @@ func (d *Dialer) aliveBackground() { d.Log.WithFields(logrus.Fields{ "dialer": d.property.Name, "tcp4": true, - "tcp6": !skipIpv6, + "tcp6": !skipTcp6, "udp4_dns": useUdpDns, - "udp6_dns": useUdpDns && !skipIpv6, + "udp6_dns": useUdpDns && !skipUdp6, }).Debugln("Connectivity check probes configured") } From 4174a973bb0e94b66445c0de658fe1fea6e82318 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 08:57:21 +0800 Subject: [PATCH 25/44] docs: add dynamic CheckOpts to English and Chinese READMEs - Add section 3 'Dynamic CheckOpts' to both README.md and README_zh.md - Document core rule: only check addresses explicitly written in config - Add probe matrix table, tcp/udp independent evaluation, recommended configs - Update upstream PR references (add PR #1011) - Restore README_zh.md (was lost during upstream merge) --- README.md | 164 ++++++++++++++++++++++++++++++++++++--------------- README_zh.md | 71 ++++++++++++++++++++++ 2 files changed, 188 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index e8615c32e1..ad1a7248f8 100644 --- a/README.md +++ b/README.md @@ -14,23 +14,23 @@ English | 简体中文

-> **This is an enhanced fork** with disaster-recovery capabilities for the `fixed` dialer mode. -> See [Releases](https://github.com/itoywh/dae/releases) for pre-built binaries. +> **This is an enhanced Fork** that adds full disaster recovery to the `fixed` dialing mode. +> Pre-built binaries are available at [Releases](https://github.com/itoywh/dae/releases). ## Fork Enhancements -### 1. fixed_fallback — Disaster-Recovery Dialer Strategy +### 1. fixed_fallback — Disaster-Recoverable Dialing Strategy -The original `fixed` dialer mode has a critical weakness: **if the fixed node goes down, traffic stops entirely**. There is no failover mechanism — it's a single point of failure. +The stock `fixed` mode has a critical flaw: **if the fixed node fails, traffic is cut off immediately** — no failover mechanism, a single point of failure. -This fork introduces `fixed_fallback`, transforming `fixed` from a fragile standalone mode into a **production-grade high-availability strategy** with full disaster-recovery semantics. +This Fork introduces `fixed_fallback`, upgrading `fixed` from a fragile single-node mode to a **production-grade high-availability strategy** with full disaster recovery semantics. --- #### Quick Start ```ini -# In your dae config (e.g., /etc/dae/config.dae): +# In dae config file (e.g. /etc/dae/config.dae): [group] my_group { policy: fixed_fallback(1, 5s, 3) @@ -40,7 +40,7 @@ my_group { check_tolerance: 60ms ``` -This means: prefer the 2nd node in the group (index 1, 0-based). If it fails after 3 connection attempts with 5 seconds timeout each → automatically switch to the best available node. When the fixed node recovers → immediately and unconditionally switch back. The 60ms tolerance prevents flapping between fallback nodes (s2 ↔ s5) during failover. +Meaning: prefer the 2nd node in the group (index 1, 0-based). On failure, timeout 5s per attempt, retry up to 3 times → on total failure, auto-switch to the best alive node. When the fixed node recovers, switch back immediately. 60ms tolerance prevents flapping between s2/s5 during failover. --- @@ -52,53 +52,52 @@ fixed_fallback(, , [, ]) | # | Parameter | Required | Description | Example | |---|-----------|----------|-------------|---------| -| 1 | **`index`** | ✅ | 0-based index of the fixed dialer in the group's node list. The first node is `0`, second is `1`, etc. Must point to a valid node — if out of range, falls through to fallback pool. | `1` → use the 2nd node | -| 2 | **`timeout`** | ✅ | Connection timeout per attempt. Supports unit suffixes: `ms` (milliseconds), `s` (seconds), `m` (minutes). Without suffix, treated as seconds (backward compatible). | `5s`, `500ms`, `2m`, `10` (10s) | -| 3 | **`retries`** | ✅ | Maximum connection retries before declaring the fixed node dead and triggering fallback. After all retries are exhausted, a WARN-level log is emitted. | `3` → retry 3 times | -| 4 | **`fallback_policy`** | ❌ | Fallback node selection strategy when the fixed node is down. If omitted, defaults to `min_moving_avg`. | See table below | +| 1 | **`index`** | ✅ | 0-based index of the fixed node in the group's node list. | `1` → use 2nd node | +| 2 | **`timeout`** | ✅ | Timeout per connection attempt. Supports unit suffixes: `ms`, `s`, `m`. No suffix = seconds (backward compatible). | `5s`, `500ms`, `2m`, `10` | +| 3 | **`retries`** | ✅ | Max retries before declaring the fixed node dead and triggering fallback. WARN-level log on exhaustion. | `3` | +| 4 | **`fallback_policy`** | ❌ | Policy for selecting fallback node after fixed node failure. Defaults to `min_moving_avg`. | See below | -##### Fallback Strategy Options +##### Fallback Policy Options -| Policy | Behavior | Best For | -|--------|----------|----------| -| `min_moving_avg` ⭐*(default)* | Selects the node with the lowest moving-average latency. Works with `check_tolerance` to prevent unnecessary switches. | General use — latency-sensitive traffic | -| `min` | Selects the node with the lowest most-recent measured latency (official name). | Environments with rapidly changing network conditions | -| `min_avg10` | Selects the node with the lowest average latency over the last 10 checks. | Environments with high latency variance | -| `random` | Randomly picks an alive node from the fallback pool. | Load balancing — when you want to distribute traffic across the fallback pool | +| Policy | Behavior | Use Case | +|--------|-----------|----------| +| `min_moving_avg` ⭐ *(default)* | Select node with lowest moving average latency. Works with `check_tolerance` to prevent flapping. | General — latency-sensitive traffic | +| `min` | Select node with lowest last measured latency (official name, same as `min_last_latency`). | Environments with rapidly changing network conditions | +| `min_avg10` | Select node with lowest average latency over last 10 checks. | Environments with high latency variance | +| `random` | Randomly select from alive fallback nodes. | Load balancing — spread traffic across fallback pool | -##### How `check_tolerance` Works with fixed_fallback +##### `check_tolerance` with `fixed_fallback` -The `check_tolerance` setting (under `[global]`) works alongside the fallback policy, **but only affects selection between fallback nodes** (e.g., s2 ↔ s5). It prevents unnecessary switching caused by minor latency fluctuations during failover. +The `check_tolerance` config (under `[global]`) works with the fallback policy, **only affecting fallback node selection during disaster recovery**, preventing s2/s5 from flapping due to minor latency fluctuations. ```ini [global] -check_tolerance: 60ms # During failover: don't switch between fallback nodes - # unless latency difference exceeds 60ms +check_tolerance: 60ms # During failover: s2↔s5 switch only if latency diff ≥60ms ``` -> **Important**: s4 recovery is completely independent of `check_tolerance`. Once s4 passes the health check, traffic returns **immediately and unconditionally** — no latency comparison, no tolerance threshold. `check_tolerance` governs "which backup to use," not "whether to take the primary back." +> **Important**: s4 recovery is completely independent of `check_tolerance`. Once s4 passes the liveness check, traffic switches back **immediately unconditionally** — no latency comparison, no tolerance threshold. `check_tolerance` governs "which backup is better", not "has the primary recovered". --- #### How It Works ``` -┌─ Normal operation ─────────────────────────────────────────┐ +┌─ Normal Flow ────────────────────────────────────────────┐ │ │ -│ fixed_fallback(1, 5s, 3, min_moving_avg) │ +│ fixed_fallback(1, 5s, 3, min_moving_avg) │ │ │ -│ 1. Try fixed node (index 1 = 2nd node) │ -│ ├─ Alive? → Use it. Done. ✅ │ -│ └─ Down/Timeout? → Retry (up to 3 times) │ +│ 1. Try fixed node (index 1 = 2nd node) │ +│ ├─ Alive? → Use it. Done ✅ │ +│ └─ Dead/timeout? → Retry (max 3 times) │ │ │ -│ 2. All 3 retries exhausted? │ -│ └─ WARN log: "fixed dialer retries exhausted (3/3)" │ -│ → Fall back to min_moving_avg among alive nodes │ -│ → INFO log: "falling back to " │ +│ 2. All 3 retries exhausted? │ +│ └─ WARN log: "fixed dialer retries exhausted (3/3)" │ +│ → Select best node by min_moving_avg │ +│ → INFO log: "falling back to " │ │ │ -│ 3. Connectivity Checker probes fixed node periodically │ -│ └─ Fixed node recovered? → Switch back immediately! 🔄 │ -│ (No check_tolerance or latency comparison — alive=go) │ +│ 3. Connectivity Checker periodically probes fixed node │ +│ └─ Fixed node recovered? → Switch back immediately 🔄 │ +│ (no check_tolerance comparison, alive = switch) │ │ │ └─────────────────────────────────────────────────────────────┘ ``` @@ -107,7 +106,7 @@ check_tolerance: 60ms # During failover: don't switch between fallback nodes #### Configuration Examples -**Example A: Simple failover** — prefer node `s4`, fall back to any alive node when unavailable: +**Example A: Simple failover** — prefer s4, fall back to other alive nodes on failure: ```ini [group] my_group { @@ -116,7 +115,7 @@ my_group { } ``` -**Example B: with random fallback** — distribute fallback traffic across all backup nodes: +**Example B: Random fallback (load balancing)** — spread fallback traffic across backup nodes: ```ini [group] my_group { @@ -125,7 +124,7 @@ my_group { } ``` -**Example C: with check_tolerance** — prevent flapping when latencies are close: +**Example C: With `check_tolerance`** — prevent oscillation when latencies are close: ```ini [global] check_tolerance: 80ms @@ -137,7 +136,7 @@ my_group { } ``` -**Example D: Aggressive timeout** — fail fast, retry only once: +**Example D: Aggressive timeout** — fast failure, single retry: ```ini policy: fixed_fallback(0, 500ms, 1, min) ``` @@ -146,29 +145,100 @@ policy: fixed_fallback(0, 500ms, 1, min) #### Compatibility -- **Backward compatible**: existing `fixed_fallback(1, 5s, 3)` configs work without changes -- **Time unit backward compatibility**: `fixed_fallback(1, 5, 3)` (no suffix) still works — treated as seconds -- **Works with all existing dialer strategies**: `fixed_fallback` is an additional strategy, all other strategies (`random`, `min_moving_avg`, etc.) continue to work as-is +- **Backward compatible**: existing `fixed_fallback(1, 5s, 3)` config works without changes +- **Timeout unit backward compatible**: `fixed_fallback(1, 5, 3)` (no unit) still works — treated as seconds +- **Coexists with all existing policies**: `fixed_fallback` is a new policy; others (`random`, `min_moving_avg`, etc.) unchanged --- -### 2. Improved Log Format +### 2. Log Timestamp Format Optimization Log timestamps now use human-readable format with `ForceFormatting` enabled: ``` -Before: INFO selected dialer: s4 ... +Before: INFO selected dialer: s4 ... After: [2026-06-13 15:04:05] INFO selected dialer: s4 ... ``` -The format `[YYYY-MM-DD HH:MM:SS]` prefix makes logs easier to read and parse with standard tools (e.g., `grep`, `awk`, log viewers). +The `[YYYY-MM-DD HH:MM:SS]` prefix aids readability and is compatible with standard log parsing tools (`grep`, `awk`, log viewers). + +--- + +### 3. Dynamic CheckOpts — Streamlined Health Check Probes + +> Corresponding upstream PR: [daeuniverse/dae#1011](https://github.com/daeuniverse/dae/pull/1011) + +Stock dae hardcodes 4 health check probes (tcp4/tcp6/udp4_dns/udp6_dns), even when the user's network doesn't support IPv6 or UDP DNS checks are not needed, causing unnecessary network probes and log noise. + +This Fork changes the logic to **dynamically decide probe types based on configuration**: + +#### Core Rule + +> **Only check addresses that are explicitly written in the config. If not written, don't check by default.** + +| Config | tcp4 | tcp6 | udp4_dns | udp6_dns | +|---|---|---|---|---| +| `tcp_check_url` has IPv4 only | ✅ | ❌ Skip | — | — | +| `tcp_check_url` has IPv6 address | ✅ | ✅ | — | — | +| `udp_check_dns` has IPv4 only | — | — | ✅ | ❌ Skip | +| `udp_check_dns` has IPv6 address | — | — | ✅ | ✅ | +| `udp_check_dns` not configured | — | — | ❌ Skip all | ❌ Skip all | +| Default config (IPv4+IPv6) | ✅ | ✅ | ✅ | ✅ | (backward compatible) | + +#### tcp and udp are independently evaluated + +Whether `tcp_check_url` has IPv6 **does NOT affect** the probe decision for `udp_check_dns`, and vice versa. They are completely independent: + +```ini +global { + # tcp has IPv6 → only tcp6 probe enabled, udp unaffected + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111' + # udp has IPv4 only → udp6 probe NOT enabled + udp_check_dns: 'dns.google:53,8.8.8.8' +} +# Actual probes: tcp4 + tcp6 + udp4_dns (3 probes, not 4) +``` + +#### Recommended Config (IPv4-only environment) + +```ini +global { + log_level: debug + # Explicitly specify IPv4 addresses → auto-skip IPv6 TCP probes + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' + # Explicitly specify IPv4 addresses → auto-skip IPv6 UDP DNS probes + udp_check_dns: 'dns.google:53,8.8.8.8' + check_tolerance: 60ms + check_interval: 30s +} +# Actual probes: tcp4 + udp4_dns (only 2 probes, minimal) +``` + +#### Skip UDP DNS probes entirely + +```ini +global { + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' + # udp_check_dns not set → skip all UDP DNS probes entirely + # Actual probes: only tcp4 (1 probe, most minimal) +} +``` + +#### Debug: View current probe configuration + +With `log_level: debug`, dae outputs the probe configuration for each dialer on startup: + +``` +DEBUG Connectivity check probes configured dialer=my-node tcp4=true tcp6=false udp4_dns=true udp6_dns=false +``` --- ### Upstream PRs -- [PR #1009](https://github.com/daeuniverse/dae/pull/1009) — fixed_fallback disaster-recovery enhancement -- [PR #1010](https://github.com/daeuniverse/dae/pull/1010) — log timestamp format improvement +- [PR #1009](https://github.com/daeuniverse/dae/pull/1009) — fixed_fallback disaster recovery enhancement +- [PR #1010](https://github.com/daeuniverse/dae/pull/1010) — Log timestamp format optimization +- [PR #1011](https://github.com/daeuniverse/dae/pull/1011) — Dynamic CheckOpts: streamline health check probes by config --- diff --git a/README_zh.md b/README_zh.md index 780e222971..db62e7426a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -164,10 +164,81 @@ policy: fixed_fallback(0, 500ms, 1, min) --- +### 3. 动态 CheckOpts — 精简健康检查探测 + +> 对应上游 PR:[daeuniverse/dae#1011](https://github.com/daeuniverse/dae/pull/1011) + +原生 dae 硬编码了 4 路健康检查探测(tcp4/tcp6/udp4_dns/udp6_dns),即使用户的网络环境不需要 IPv6 或没有配置 UDP DNS 检查,也会无条件发起全部探测,造成不必要的网络请求和日志噪音。 + +本 Fork 将该逻辑改为**按配置动态决定探测类型**: + +#### 核心规则 + +> **只有配置里显式写了需要去 check 的地址时才去 check,没有写就默认不去 check。** + +| 配置情况 | tcp4 | tcp6 | udp4_dns | udp6_dns | +|---|---|---|---|---| +| `tcp_check_url` 只配了 IPv4 地址 | ✅ | ❌ 跳过 | — | — | +| `tcp_check_url` 含 IPv6 地址 | ✅ | ✅ | — | — | +| `udp_check_dns` 只配了 IPv4 地址 | — | — | ✅ | ❌ 跳过 | +| `udp_check_dns` 含 IPv6 地址 | — | — | ✅ | ✅ | +| `udp_check_dns` 未配置 | — | — | ❌ 完全跳过 | ❌ 完全跳过 | +| 默认配置(含 IPv4+IPv6) | ✅ | ✅ | ✅ | ✅ |(向后兼容) + +#### tcp 和 udp 独立判断 + +`tcp_check_url` 里有没有 IPv6 **不会影响** `udp_check_dns` 的探测决策,反之亦然。两者完全独立: + +```ini +global { + # tcp 有 IPv6 → 只启用 tcp6 探测,udp 不受影响 + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111' + # udp 只有 IPv4 → 不启用 udp6 探测 + udp_check_dns: 'dns.google:53,8.8.8.8' +} +# 实际探测:tcp4 + tcp6 + udp4_dns(3 路,非 4 路) +``` + +#### 推荐配置(IPv4 单栈环境) + +```ini +global { + log_level: debug + # 显式指定 IPv4 地址,自动跳过 IPv6 TCP 探测 + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' + # 显式指定 IPv4 地址,自动跳过 IPv6 UDP DNS 探测 + udp_check_dns: 'dns.google:53,8.8.8.8' + check_tolerance: 60ms + check_interval: 30s +} +# 实际探测:tcp4 + udp4_dns(仅 2 路,最精简) +``` + +#### 完全跳过 UDP DNS 探测 + +```ini +global { + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' + # udp_check_dns 不写 → 完全跳过 UDP DNS 探测 + # 实际探测:仅 tcp4(1 路,最最精简) +} +``` + +#### 调试:查看当前探测配置 + +启用 `log_level: debug` 后,dae 启动时会输出每条拨号的探测配置: + +``` +DEBUG Connectivity check probes configured dialer=my-node tcp4=true tcp6=false udp4_dns=true udp6_dns=false +``` + +--- + ### 上游 PR - [PR #1009](https://github.com/daeuniverse/dae/pull/1009) — fixed_fallback 灾备增强 - [PR #1010](https://github.com/daeuniverse/dae/pull/1010) — 日志时间戳格式优化 +- [PR #1011](https://github.com/daeuniverse/dae/pull/1011) — 动态 CheckOpts:按配置精简健康检查探测 --- From 4ab2d13b0b8ae085dae6814330594939b34f8aec Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 09:08:42 +0800 Subject: [PATCH 26/44] debug: print CheckDnsOptionRaw.Raw to diagnose UDP check issue --- component/outbound/dialer/connectivity_check.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index fa5d90dd9b..11348d54da 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -626,6 +626,14 @@ func (d *Dialer) aliveBackground() { // - Skip all UDP DNS probes when udp_check_dns is not configured skipTcp6 := shouldSkipTcp6Probes(d.TcpCheckOptionRaw.Raw) skipUdp6 := shouldSkipUdp6Probes(d.CheckDnsOptionRaw.Raw) + // DEBUG: print CheckDnsOptionRaw.Raw to diagnose why UDP checks are still running + if d.Log.IsLevelEnabled(logrus.DebugLevel) { + d.Log.WithFields(logrus.Fields{ + "dialer": d.property.Name, + "CheckDnsOptionRaw.Raw": d.CheckDnsOptionRaw.Raw, + "len(Raw)": len(d.CheckDnsOptionRaw.Raw), + }).Debugln("aliveBackground: CheckDnsOptionRaw before hasUdpDnsConfig") + } useUdpDns := hasUdpDnsConfig(d.CheckDnsOptionRaw.Raw) var CheckOpts []*CheckOption From c69d284bbf0ae57229a03ae829d33faad0ff94af Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 09:18:53 +0800 Subject: [PATCH 27/44] feat: make connectivity checks fully configuration-driven - Remove default values for tcp_check_url, udp_check_dns, and check_interval - If check_interval=0 or not configured, skip connectivity check entirely - Only add TCP checks if tcp_check_url is configured - Only add UDP DNS checks if udp_check_dns is configured - Users can now completely disable health checks by omitting these options --- .../outbound/dialer/connectivity_check.go | 47 +++++++++++-------- config/config.go | 6 +-- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index 11348d54da..45df9d119a 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -522,6 +522,13 @@ func hasUdpDnsConfig(raw []string) bool { } func (d *Dialer) aliveBackground() { + // If check_interval is 0 or not configured, skip connectivity check entirely + if d.CheckInterval == 0 { + d.Log.WithField("dialer", d.Property().Name). + Debugln("Connectivity check disabled (check_interval=0)") + return + } + cycle := d.CheckInterval var tcpSomark uint32 var mptcp bool @@ -621,25 +628,20 @@ func (d *Dialer) aliveBackground() { CheckFunc: makeDnsCheckFunc(func(o *CheckDnsOption) netip.Addr { return o.Ip6 }, &udpNetwork), } // Build CheckOpts dynamically based on configuration: - // - Skip IPv6 TCP probes when tcp_check_url only has explicit IPv4 addresses - // - Skip IPv6 UDP DNS probes when udp_check_dns only has explicit IPv4 addresses - // - Skip all UDP DNS probes when udp_check_dns is not configured - skipTcp6 := shouldSkipTcp6Probes(d.TcpCheckOptionRaw.Raw) - skipUdp6 := shouldSkipUdp6Probes(d.CheckDnsOptionRaw.Raw) - // DEBUG: print CheckDnsOptionRaw.Raw to diagnose why UDP checks are still running - if d.Log.IsLevelEnabled(logrus.DebugLevel) { - d.Log.WithFields(logrus.Fields{ - "dialer": d.property.Name, - "CheckDnsOptionRaw.Raw": d.CheckDnsOptionRaw.Raw, - "len(Raw)": len(d.CheckDnsOptionRaw.Raw), - }).Debugln("aliveBackground: CheckDnsOptionRaw before hasUdpDnsConfig") - } - useUdpDns := hasUdpDnsConfig(d.CheckDnsOptionRaw.Raw) + // - Only add TCP checks if tcp_check_url is configured + // - Only add UDP DNS checks if udp_check_dns is configured + // - Skip IPv6 probes when only IPv4 addresses are explicitly configured + useTcpCheck := len(d.TcpCheckOptionRaw.Raw) > 0 + useUdpDns := len(d.CheckDnsOptionRaw.Raw) > 0 + skipTcp6 := useTcpCheck && shouldSkipTcp6Probes(d.TcpCheckOptionRaw.Raw) + skipUdp6 := useUdpDns && shouldSkipUdp6Probes(d.CheckDnsOptionRaw.Raw) var CheckOpts []*CheckOption - CheckOpts = append(CheckOpts, tcp4CheckOpt) - if !skipTcp6 { - CheckOpts = append(CheckOpts, tcp6CheckOpt) + if useTcpCheck { + CheckOpts = append(CheckOpts, tcp4CheckOpt) + if !skipTcp6 { + CheckOpts = append(CheckOpts, tcp6CheckOpt) + } } if useUdpDns { CheckOpts = append(CheckOpts, udp4CheckDnsOpt) @@ -648,11 +650,18 @@ func (d *Dialer) aliveBackground() { } } + // If neither TCP nor UDP checks are configured, return early + if len(CheckOpts) == 0 { + d.Log.WithField("dialer", d.Property().Name). + Debugln("No connectivity checks configured, skipping") + return + } + if d.Log.IsLevelEnabled(logrus.DebugLevel) { d.Log.WithFields(logrus.Fields{ "dialer": d.property.Name, - "tcp4": true, - "tcp6": !skipTcp6, + "tcp4": useTcpCheck, + "tcp6": useTcpCheck && !skipTcp6, "udp4_dns": useUdpDns, "udp6_dns": useUdpDns && !skipUdp6, }).Debugln("Connectivity check probes configured") diff --git a/config/config.go b/config/config.go index e688e8812f..a8f27efbb9 100644 --- a/config/config.go +++ b/config/config.go @@ -24,10 +24,10 @@ type Global struct { LogLevel string `mapstructure:"log_level" default:"info"` // We use DirectTcpCheckUrl to check (tcp)*(ipv4/ipv6) connectivity for direct. // DirectTcpCheckUrl string `mapstructure:"direct_tcp_check_url" default:"http://www.qualcomm.cn/generate_204"` - TcpCheckUrl []string `mapstructure:"tcp_check_url" default:"http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111"` + TcpCheckUrl []string `mapstructure:"tcp_check_url"` TcpCheckHttpMethod string `mapstructure:"tcp_check_http_method" default:"HEAD"` // Use 'HEAD' because some server implementations bypass accounting for this kind of traffic. - UdpCheckDns []string `mapstructure:"udp_check_dns" default:"dns.google:53,8.8.8.8,2001:4860:4860::8888"` - CheckInterval time.Duration `mapstructure:"check_interval" default:"30s"` + UdpCheckDns []string `mapstructure:"udp_check_dns"` + CheckInterval time.Duration `mapstructure:"check_interval"` CheckTolerance time.Duration `mapstructure:"check_tolerance" default:"0"` LanInterface []string `mapstructure:"lan_interface"` WanInterface []string `mapstructure:"wan_interface"` From 52342a265438d3b999a7e5fadd5848808f3f08d4 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 09:20:14 +0800 Subject: [PATCH 28/44] docs: update example.dae with configuration-driven check notes - Add notes about tcp_check_url, udp_check_dns, check_interval being optional - Users can now completely disable health checks by omitting these options - Set check_interval to 0 to disable all checks --- example.dae | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/example.dae b/example.dae index c6c884dd03..4416a5598b 100644 --- a/example.dae +++ b/example.dae @@ -66,6 +66,13 @@ global { ##### Node connectivity check. # These options, as defaults, are effective when no definition is given in the group. + # + # NOTE: All connectivity check options are now fully configuration-driven: + # - If tcp_check_url is NOT configured (commented out or omitted), NO TCP check will be performed. + # - If udp_check_dns is NOT configured (commented out or omitted), NO UDP DNS check will be performed. + # - If check_interval is 0 or NOT configured, NO connectivity check will be performed at all. + # - Only the protocols/addresses explicitly written in config will be checked. + # - This allows users to completely disable health checks for maximum performance. # Host of URL should have both IPv4 and IPv6 if you have double stack in local. # First is URL, others are IP addresses if given. @@ -84,6 +91,7 @@ global { #udp_check_dns: 'dns.google:53' udp_check_dns: 'dns.google:53,8.8.8.8,2001:4860:4860::8888' + # Interval between connectivity checks. Set to 0 or omit to completely disable health checks. check_interval: 30s # Group will switch node only when new_latency <= old_latency - tolerance. From 390a9f8453077793519e4bcf48e025d7baf658ea Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 09:40:24 +0800 Subject: [PATCH 29/44] docs: update READMEs for fully configuration-driven health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Section 3: rewrite to reflect new opt-in behavior - All health check options are now fully optional - check_interval=0 or not set → no checks at all - Add zero-check config example for maximum performance - Add migration note for users upgrading from stock dae --- README.md | 73 ++++++++++++++++++++++++++++------------------------ README_zh.md | 71 +++++++++++++++++++++++++++----------------------- 2 files changed, 79 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index ad1a7248f8..c66cec8e64 100644 --- a/README.md +++ b/README.md @@ -164,74 +164,81 @@ The `[YYYY-MM-DD HH:MM:SS]` prefix aids readability and is compatible with stand --- -### 3. Dynamic CheckOpts — Streamlined Health Check Probes +### 3. Fully Configuration-Driven Health Checks > Corresponding upstream PR: [daeuniverse/dae#1011](https://github.com/daeuniverse/dae/pull/1011) -Stock dae hardcodes 4 health check probes (tcp4/tcp6/udp4_dns/udp6_dns), even when the user's network doesn't support IPv6 or UDP DNS checks are not needed, causing unnecessary network probes and log noise. - -This Fork changes the logic to **dynamically decide probe types based on configuration**: +Stock dae hardcodes default values for `tcp_check_url`, `udp_check_dns`, and `check_interval`, so health checks always run even when the user didn't explicitly configure them. This Fork makes all health check options **fully optional** — if you don't write it, it won't check. #### Core Rule -> **Only check addresses that are explicitly written in the config. If not written, don't check by default.** +> **Health checks are now completely opt-in. Nothing runs unless you explicitly configure it.** + +| Config | Behavior | +|---|---| +| `check_interval` not set or `0s` | **All health checks disabled**. Zero network probes. | +| `tcp_check_url` not set | No TCP connectivity checks at all. | +| `tcp_check_url` set (IPv4 only) | TCP IPv4 check only. IPv6 TCP probe auto-skipped. | +| `tcp_check_url` set (with IPv6) | TCP IPv4 + IPv6 checks both run. | +| `udp_check_dns` not set | No UDP DNS connectivity checks at all. | +| `udp_check_dns` set (IPv4 only) | UDP IPv4 DNS check only. IPv6 UDP probe auto-skipped. | +| `udp_check_dns` set (with IPv6) | UDP IPv4 + IPv6 DNS checks both run. | -| Config | tcp4 | tcp6 | udp4_dns | udp6_dns | -|---|---|---|---|---| -| `tcp_check_url` has IPv4 only | ✅ | ❌ Skip | — | — | -| `tcp_check_url` has IPv6 address | ✅ | ✅ | — | — | -| `udp_check_dns` has IPv4 only | — | — | ✅ | ❌ Skip | -| `udp_check_dns` has IPv6 address | — | — | ✅ | ✅ | -| `udp_check_dns` not configured | — | — | ❌ Skip all | ❌ Skip all | -| Default config (IPv4+IPv6) | ✅ | ✅ | ✅ | ✅ | (backward compatible) | +#### Zero-Check Config (Maximum Performance) -#### tcp and udp are independently evaluated +```ini +global { + # No tcp_check_url, no udp_check_dns, no check_interval + # → Zero connectivity checks. Zero network probes. Maximum performance. + log_level: info +} +``` -Whether `tcp_check_url` has IPv6 **does NOT affect** the probe decision for `udp_check_dns`, and vice versa. They are completely independent: +#### TCP Check Only ```ini global { - # tcp has IPv6 → only tcp6 probe enabled, udp unaffected - tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111' - # udp has IPv4 only → udp6 probe NOT enabled - udp_check_dns: 'dns.google:53,8.8.8.8' + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' + check_interval: 60s + # udp_check_dns not set → no UDP checks } -# Actual probes: tcp4 + tcp6 + udp4_dns (3 probes, not 4) +# Actual probes: tcp4 only (1 probe) ``` -#### Recommended Config (IPv4-only environment) +#### TCP + UDP, IPv4 Only (No IPv6) ```ini global { - log_level: debug - # Explicitly specify IPv4 addresses → auto-skip IPv6 TCP probes tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' - # Explicitly specify IPv4 addresses → auto-skip IPv6 UDP DNS probes udp_check_dns: 'dns.google:53,8.8.8.8' - check_tolerance: 60ms - check_interval: 30s + check_interval: 60s + check_tolerance: 50ms } -# Actual probes: tcp4 + udp4_dns (only 2 probes, minimal) +# Actual probes: tcp4 + udp4_dns (2 probes, no IPv6) ``` -#### Skip UDP DNS probes entirely +#### Full Checks (IPv4 + IPv6) ```ini global { - tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' - # udp_check_dns not set → skip all UDP DNS probes entirely - # Actual probes: only tcp4 (1 probe, most minimal) + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111' + udp_check_dns: 'dns.google:53,8.8.8.8,2001:4860:4860::8888' + check_interval: 60s } +# Actual probes: tcp4 + tcp6 + udp4_dns + udp6_dns (4 probes) ``` -#### Debug: View current probe configuration +#### Debug: View Current Probe Configuration With `log_level: debug`, dae outputs the probe configuration for each dialer on startup: ``` -DEBUG Connectivity check probes configured dialer=my-node tcp4=true tcp6=false udp4_dns=true udp6_dns=false +DEBUG Connectivity check probes configured dialer=my-node tcp4=true tcp6=false udp4_dns=false udp6_dns=false +DEBUG Connectivity check disabled (check_interval=0) dialer=my-node ``` +> **Migration note**: If you upgrade from stock dae and want health checks, you must now explicitly add `tcp_check_url`, `udp_check_dns`, and `check_interval` to your config. They no longer have defaults. + --- ### Upstream PRs diff --git a/README_zh.md b/README_zh.md index db62e7426a..1f48094d26 100644 --- a/README_zh.md +++ b/README_zh.md @@ -164,64 +164,68 @@ policy: fixed_fallback(0, 500ms, 1, min) --- -### 3. 动态 CheckOpts — 精简健康检查探测 +### 3. 完全配置驱动的健康检查 > 对应上游 PR:[daeuniverse/dae#1011](https://github.com/daeuniverse/dae/pull/1011) -原生 dae 硬编码了 4 路健康检查探测(tcp4/tcp6/udp4_dns/udp6_dns),即使用户的网络环境不需要 IPv6 或没有配置 UDP DNS 检查,也会无条件发起全部探测,造成不必要的网络请求和日志噪音。 - -本 Fork 将该逻辑改为**按配置动态决定探测类型**: +原生 dae 为 `tcp_check_url`、`udp_check_dns`、`check_interval` 都设置了默认值,即使用户没有明确配置,健康检查也会无条件运行。本 Fork 将这些选项改为**完全可选**——不写就不检查。 #### 核心规则 -> **只有配置里显式写了需要去 check 的地址时才去 check,没有写就默认不去 check。** +> **健康检查现在是完全选配的。除非你显式配置,否则什么都不会运行。** + +| 配置情况 | 行为 | +|---|---| +| `check_interval` 未设置或设为 `0s` | **全部健康检查禁用**。零网络探测。 | +| `tcp_check_url` 未设置 | 不进行任何 TCP 连通性检查。 | +| `tcp_check_url` 已设置(仅 IPv4) | 只进行 TCP IPv4 检查。自动跳过 IPv6 TCP 探测。 | +| `tcp_check_url` 已设置(含 IPv6) | TCP IPv4 + IPv6 检查均运行。 | +| `udp_check_dns` 未设置 | 不进行任何 UDP DNS 连通性检查。 | +| `udp_check_dns` 已设置(仅 IPv4) | 只进行 UDP IPv4 DNS 检查。自动跳过 IPv6 UDP 探测。 | +| `udp_check_dns` 已设置(含 IPv6) | UDP IPv4 + IPv6 DNS 检查均运行。 | -| 配置情况 | tcp4 | tcp6 | udp4_dns | udp6_dns | -|---|---|---|---|---| -| `tcp_check_url` 只配了 IPv4 地址 | ✅ | ❌ 跳过 | — | — | -| `tcp_check_url` 含 IPv6 地址 | ✅ | ✅ | — | — | -| `udp_check_dns` 只配了 IPv4 地址 | — | — | ✅ | ❌ 跳过 | -| `udp_check_dns` 含 IPv6 地址 | — | — | ✅ | ✅ | -| `udp_check_dns` 未配置 | — | — | ❌ 完全跳过 | ❌ 完全跳过 | -| 默认配置(含 IPv4+IPv6) | ✅ | ✅ | ✅ | ✅ |(向后兼容) +#### 零检查配置(最高性能) -#### tcp 和 udp 独立判断 +```ini +global { + # 不写 tcp_check_url、udp_check_dns、check_interval + # → 零健康检查。零网络探测。最高性能。 + log_level: info +} +``` -`tcp_check_url` 里有没有 IPv6 **不会影响** `udp_check_dns` 的探测决策,反之亦然。两者完全独立: +#### 只开 TCP 检查 ```ini global { - # tcp 有 IPv6 → 只启用 tcp6 探测,udp 不受影响 - tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111' - # udp 只有 IPv4 → 不启用 udp6 探测 - udp_check_dns: 'dns.google:53,8.8.8.8' + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' + check_interval: 60s + # udp_check_dns 不写 → 无 UDP 检查 } -# 实际探测:tcp4 + tcp6 + udp4_dns(3 路,非 4 路) +# 实际探测:仅 tcp4(1 路) ``` -#### 推荐配置(IPv4 单栈环境) +#### TCP + UDP,仅 IPv4(无 IPv6) ```ini global { - log_level: debug - # 显式指定 IPv4 地址,自动跳过 IPv6 TCP 探测 tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' - # 显式指定 IPv4 地址,自动跳过 IPv6 UDP DNS 探测 udp_check_dns: 'dns.google:53,8.8.8.8' - check_tolerance: 60ms - check_interval: 30s + check_interval: 60s + check_tolerance: 50ms } -# 实际探测:tcp4 + udp4_dns(仅 2 路,最精简) +# 实际探测:tcp4 + udp4_dns(2 路,无 IPv6) ``` -#### 完全跳过 UDP DNS 探测 +#### 全部检查(IPv4 + IPv6) ```ini global { - tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1' - # udp_check_dns 不写 → 完全跳过 UDP DNS 探测 - # 实际探测:仅 tcp4(1 路,最最精简) + tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111' + udp_check_dns: 'dns.google:53,8.8.8.8,2001:4860:4860::8888' + check_interval: 60s } +# 实际探测:tcp4 + tcp6 + udp4_dns + udp6_dns(4 路) ``` #### 调试:查看当前探测配置 @@ -229,9 +233,12 @@ global { 启用 `log_level: debug` 后,dae 启动时会输出每条拨号的探测配置: ``` -DEBUG Connectivity check probes configured dialer=my-node tcp4=true tcp6=false udp4_dns=true udp6_dns=false +DEBUG Connectivity check probes configured dialer=my-node tcp4=true tcp6=false udp4_dns=false udp6_dns=false +DEBUG Connectivity check disabled (check_interval=0) dialer=my-node ``` +> **迁移提示**:如果从原生 dae 升级并希望保留健康检查,现在需要在配置中显式添加 `tcp_check_url`、`udp_check_dns` 和 `check_interval`,它们不再有默认值。 + --- ### 上游 PR From 0ba247fec1ec18021559a23fd9fbe0648930e946 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 09:52:21 +0800 Subject: [PATCH 30/44] feat: add operational logs for node alive/dead transitions - Add WARN log when node becomes DEAD in markUnavailableInternal - Add INFO log when node becomes ALIVE in markAvailable - Logs include node name and network type for easy debugging - Helps monitor node health and fixed_fallback behavior in production --- .../outbound/dialer/connectivity_check.go | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index 45df9d119a..6be1bab64e 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -1063,6 +1063,25 @@ func (d *Dialer) markUnavailableInternal(typ *NetworkType, force bool, isTraffic wasAlive := collection.Alive.Load() collection.Alive.Store(alive) + // Log alive/dead transitions for operational visibility. + if d.Log != nil { + nodeName := "" + if d.property != nil { + nodeName = d.property.Name + } + if wasAlive && !alive { + d.Log.WithFields(logrus.Fields{ + "dialer": nodeName, + "network": typ.String(), + }).Warnln("Node became DEAD") + } else if !wasAlive && alive { + d.Log.WithFields(logrus.Fields{ + "dialer": nodeName, + "network": typ.String(), + }).Infoln("Node became ALIVE") + } + } + update := collectionUpdate{ alive: alive, movingAverage: collection.MovingAverage, @@ -1111,6 +1130,18 @@ func (d *Dialer) markAvailable(typ *NetworkType, latency time.Duration) (collect isRevival := !wasAlive d.NotifyHealthCheckResult(typ, true, isRevival) if isRevival { + // Log node revival for operational visibility. + if d.Log != nil { + nodeName := "" + if d.property != nil { + nodeName = d.property.Name + } + d.Log.WithFields(logrus.Fields{ + "dialer": nodeName, + "network": typ.String(), + "latency": latency.String(), + }).Infoln("Node became ALIVE") + } d.notifyAliveTransition(typ, true) } From b1dae4e90a28a1ba12dc51451372025615d5e3b4 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:07:41 +0800 Subject: [PATCH 31/44] docs: add node status operational logging (Section 4) to READMEs --- README.md | 42 +++++++++++++++++++++++++++++++++++++++++- README_zh.md | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c66cec8e64..350982fd26 100644 --- a/README.md +++ b/README.md @@ -241,11 +241,51 @@ DEBUG Connectivity check disabled (check_interval=0) dialer=my-node --- +### 4. Node Status Operational Logging + +To enable fine-grained operational analysis without enabling debug mode, this Fork adds WARN/INFO-level logs for node state transitions: + +#### Node-Level Events + +| Event | Level | Trigger | +|---|---|---| +| `Node became DEAD` | **WARN** | Node transitions from ALIVE → DEAD (shows `network` type) | +| `Node became ALIVE` | **INFO** | Node recovers from DEAD → ALIVE (shows `latency` + `network`) | + +#### Fixed-Fallback Events (existing) + +| Event | Level | Trigger | +|---|---|---| +| `fixed dialer dead, starting retry` | **WARN** | Fixed node detected DEAD, entering retry phase | +| `fixed dialer retry N/M` | **INFO** | Per-retry attempt count | +| `fixed dialer retries exhausted, falling back` | **WARN** | All retries exhausted, switching to fallback pool | +| `fixed dialer recovered, traffic returned` | **INFO** | Fixed node revived, traffic unconditionally switched back | + +#### Log Level Guide + +| `log_level` | What You See | +|---|---| +| `info` | All WARN/INFO operational events above — ideal for production monitoring | +| `debug` | Everything above + per-check latency data + probe configuration — for deep analysis | + +#### Example Log Output + +``` +[2026-06-14 02:02:36] WARN Node became DEAD dialer=s4 network=tcp4 +[2026-06-14 02:02:45] WARN fixed dialer dead, starting retry (timeout=3s, max_retries=3) +[2026-06-14 02:03:19] INFO fixed dialer retry 1/3 +[2026-06-14 02:03:36] WARN fixed dialer retries exhausted (3/3), falling back to min_moving_avg +[2026-06-14 02:15:00] INFO Node became ALIVE dialer=s4 network=tcp4 latency=156ms +[2026-06-14 02:15:00] INFO fixed dialer recovered, traffic returned +``` + +--- + ### Upstream PRs - [PR #1009](https://github.com/daeuniverse/dae/pull/1009) — fixed_fallback disaster recovery enhancement - [PR #1010](https://github.com/daeuniverse/dae/pull/1010) — Log timestamp format optimization -- [PR #1011](https://github.com/daeuniverse/dae/pull/1011) — Dynamic CheckOpts: streamline health check probes by config +- [PR #1011](https://github.com/daeuniverse/dae/pull/1011) — Fully configurable health checks (opt-in) --- diff --git a/README_zh.md b/README_zh.md index 1f48094d26..6a88b81e0d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -241,11 +241,51 @@ DEBUG Connectivity check disabled (check_interval=0) dialer=my-node --- +### 4. 节点状态运营日志 + +为了在不开启 debug 模式的情况下也能进行精细化运营分析,本 Fork 为节点状态变化增加了 WARN/INFO 级别日志: + +#### 节点级事件 + +| 事件 | 级别 | 触发条件 | +|---|---|---| +| `Node became DEAD` | **WARN** | 节点从 ALIVE 变为 DEAD(显示 `network` 类型) | +| `Node became ALIVE` | **INFO** | 节点从 DEAD 恢复为 ALIVE(显示 `latency` + `network`) | + +#### Fixed-Fallback 事件(已有) + +| 事件 | 级别 | 触发条件 | +|---|---|---| +| `fixed dialer dead, starting retry` | **WARN** | 固定节点变 DEAD,进入重试阶段 | +| `fixed dialer retry N/M` | **INFO** | 每次重试计数 | +| `fixed dialer retries exhausted, falling back` | **WARN** | 重试耗尽,切到 fallback 池 | +| `fixed dialer recovered, traffic returned` | **INFO** | 固定节点复活,无条件切回 | + +#### 日志等级指南 + +| `log_level` | 可见内容 | +|---|---| +| `info` | 以上所有 WARN/INFO 运营事件 — 适合生产监控 | +| `debug` | 以上全部 + 每次检查的延迟数据 + 探测配置 — 适合深度分析 | + +#### 日志样例 + +``` +[2026-06-14 02:02:36] WARN Node became DEAD dialer=s4 network=tcp4 +[2026-06-14 02:02:45] WARN fixed dialer dead, starting retry (timeout=3s, max_retries=3) +[2026-06-14 02:03:19] INFO fixed dialer retry 1/3 +[2026-06-14 02:03:36] WARN fixed dialer retries exhausted (3/3), falling back to min_moving_avg +[2026-06-14 02:15:00] INFO Node became ALIVE dialer=s4 network=tcp4 latency=156ms +[2026-06-14 02:15:00] INFO fixed dialer recovered, traffic returned +``` + +--- + ### 上游 PR - [PR #1009](https://github.com/daeuniverse/dae/pull/1009) — fixed_fallback 灾备增强 - [PR #1010](https://github.com/daeuniverse/dae/pull/1010) — 日志时间戳格式优化 -- [PR #1011](https://github.com/daeuniverse/dae/pull/1011) — 动态 CheckOpts:按配置精简健康检查探测 +- [PR #1011](https://github.com/daeuniverse/dae/pull/1011) — 完全配置化健康检查(opt-in) --- From 7a23070bf3aaf529e18778f7da6b756e0bb4a01f Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:14:28 +0800 Subject: [PATCH 32/44] fix(logger): force CST (UTC+8) timezone for log timestamps On minimal OpenWrt/ImmortalWrt without tzdata setup, Go defaults to UTC. This makes dae logs show CST (Beijing time) by setting time.Local in the logger package init(). Try Asia/Shanghai first for proper DST; fall back to FixedZone. --- pkg/logger/logger.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 615a6e1ae6..87b7be3873 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -6,11 +6,23 @@ package logger import ( + "time" + "github.com/sirupsen/logrus" prefixed "github.com/x-cray/logrus-prefixed-formatter" "gopkg.in/natefinch/lumberjack.v2" ) +func init() { + // Always use CST (UTC+8) for log timestamps. + // On minimal OpenWrt/ImmortalWrt without tzdata, fall back to fixed offset. + if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil { + time.Local = loc + } else { + time.Local = time.FixedZone("CST", 8*3600) + } +} + func SetLogger(log *logrus.Logger, logLevel string, disableTimestamp bool, logFileOpt *lumberjack.Logger) { level, err := logrus.ParseLevel(logLevel) if err != nil { From 27763ea073f0b5cc08b813cb6a8367557a926164 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:45:17 +0800 Subject: [PATCH 33/44] ci: auto-update GitHub Release after successful build Add update-release job to build.yml that: - Downloads the x86_64 artifact after successful build - Deletes the old dae-linux-x86_64 asset from pre-release (ID 338955206) - Uploads the new binary to the pre-release - Updates the release body with the latest commit SHA Uses scripts/update-release.py for the GitHub API calls. --- .github/workflows/build.yml | 29 ++++++++++ scripts/update-release.py | 107 ++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 scripts/update-release.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1dc499247..593a461b7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,3 +53,32 @@ jobs: check-run-id: "dae-bot[bot]/main-build-passed" check-run-conclusion: ${{ needs.build.result }} secrets: inherit + + update-release: + if: needs.build.result == 'success' + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Download x86_64 artifact + uses: actions/download-artifact@v4 + with: + name: dae-linux-x86_64.zip + path: /tmp/dae_artifact/ + + - name: Extract dae binary + run: | + unzip -o /tmp/dae_artifact/dae-linux-x86_64.zip -d /tmp/dae_artifact/ + ls -lh /tmp/dae_artifact/dae-linux-x86_64 + + - name: Update GitHub Release (delete old + upload new + update body) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + GITHUB_SHA: ${{ github.sha }} + run: | + python3 scripts/update-release.py "338955206" "/tmp/dae_artifact/dae-linux-x86_64" diff --git a/scripts/update-release.py b/scripts/update-release.py new file mode 100644 index 0000000000..df297b8ad3 --- /dev/null +++ b/scripts/update-release.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Update GitHub Release after successful dae build. +Usage: python3 update-release.py +""" + +import os +import sys +import json +import urllib.request +import urllib.error +import ssl + +TOKEN = os.environ.get("GITHUB_TOKEN", "") +REPO = os.environ.get("GITHUB_REPO", "itoywh/dae") + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + + +def api(method, url, data=None): + headers = { + "Authorization": f"token {TOKEN}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if data is not None: + headers["Content-Type"] = "application/json" + data = data.encode() if isinstance(data, str) else data + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + resp = urllib.request.urlopen(req, context=ctx, timeout=30) + if resp.status == 204: + return {"ok": True} + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode() + print(f" HTTP {e.code}: {body}", file=sys.stderr) + sys.exit(1) + + +def main(): + if len(sys.argv) < 3: + print("Usage: python3 update-release.py ") + sys.exit(1) + + release_id = sys.argv[1] + binary_path = sys.argv[2] + + if not TOKEN: + print("GITHUB_TOKEN not set!", file=sys.stderr) + sys.exit(1) + + print(f"Updating release {release_id} in {REPO}...") + + # 1. Get release, find old asset + release = api("GET", f"https://api.github.com/repos/{REPO}/releases/{release_id}") + print(f" Release: {release['name']}") + print(f" Tag: {release['tag_name']}") + print(f" HTML: {release['html_url']}") + + for asset in release.get("assets", []): + if asset["name"] == "dae-linux-x86_64": + print(f" Deleting old asset {asset['id']} ({asset['name']})...") + api("DELETE", f"https://api.github.com/repos/{REPO}/releases/assets/{asset['id']}") + break + else: + print(" No existing asset found (first upload).") + + # 2. Upload new binary + print(f" Uploading {binary_path}...") + with open(binary_path, "rb") as f: + binary_data = f.read() + + upload_url = f"https://uploads.github.com/repos/{REPO}/releases/{release_id}/assets?name=dae-linux-x86_64" + headers = { + "Authorization": f"token {TOKEN}", + "Content-Type": "application/octet-stream", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Length": str(len(binary_data)), + } + req = urllib.request.Request(upload_url, data=binary_data, method="POST", headers=headers) + resp = urllib.request.urlopen(req, context=ctx, timeout=120) + result = json.loads(resp.read()) + print(f" Uploaded: {result['name']} ({result['size']} bytes)") + + # 3. Update release body with latest commit SHA + sha = os.environ.get("GITHUB_SHA", "")[:7] + body = release["body"] + version_line = f"**dae version**: `unstable-{sha}`" + if sha and sha not in body: + lines = body.split("\n") + # Insert version line after the first heading + new_lines = [lines[0], "", version_line, ""] + new_lines.extend(lines[1:]) + new_body = "\n".join(new_lines) + api("PATCH", f"https://api.github.com/repos/{REPO}/releases/{release_id}", + json.dumps({"body": new_body})) + print(f" Release body updated with {version_line}") + else: + print(" Release body already up-to-date (commit SHA present).") + + print("Done!") + + +if __name__ == "__main__": + main() From 2fbc3c4d153a560fdee9f5618b8ddd3a92650668 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:48:02 +0800 Subject: [PATCH 34/44] ci: auto-generate changelog in Release body on every build update-release.py now: - Runs 'git log' to collect recent commits - Detects whether the release body already has a changelog section - Appends only new commits (no duplicates) - Rebuilds the entire ## Changelog section each time for consistency - Uses a sentinel marker (---) to separate header content from changelog --- scripts/update-release.py | 142 ++++++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 14 deletions(-) diff --git a/scripts/update-release.py b/scripts/update-release.py index df297b8ad3..9f10e46208 100644 --- a/scripts/update-release.py +++ b/scripts/update-release.py @@ -1,11 +1,16 @@ #!/usr/bin/env python3 """Update GitHub Release after successful dae build. +- Deletes old binary asset, uploads new one +- Auto-generates changelog from git log and updates release body + Usage: python3 update-release.py """ import os +import re import sys import json +import subprocess import urllib.request import urllib.error import ssl @@ -17,6 +22,9 @@ ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE +CHANGELOG_MARKER = "---\n\n" +MAX_CHANGELOG_ENTRIES = 30 + def api(method, url, data=None): headers = { @@ -39,6 +47,116 @@ def api(method, url, data=None): sys.exit(1) +def git_log(old_sha, new_sha="HEAD", limit=0): + """Run git log between two revisions, returning list of (sha, subject) tuples.""" + args = ["git", "log", "--format=%H||%s", "--reverse"] + if old_sha: + args.append(f"{old_sha}..{new_sha}") + elif limit: + args.append(f"-{limit}") + else: + args.append("-10") + result = subprocess.run(args, capture_output=True, text=True) + if result.returncode != 0: + print(f" Warning: git log failed: {result.stderr}", file=sys.stderr) + return [] + entries = [] + for line in result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split("||", 1) + if len(parts) == 2: + entries.append((parts[0][:7], parts[1])) + return entries + + +def extract_last_changelog_sha(body): + """Find the most recent commit SHA already in the changelog section.""" + # Look for lines like: * `abc1234` desc ... + matches = re.findall(r'\* `([a-f0-9]+)` ', body) + if matches: + return matches[0] # first match = newest entry + return None + + +def build_changelog_body(commits): + """Build a formatted changelog block from commit tuples.""" + if not commits: + return "" + lines = ["## Changelog", "", f"*Auto-generated changelog (last {len(commits)} commits)*", ""] + for sha, subject in reversed(commits): # newest first + lines.append(f"* `{sha}` {subject}") + lines.append("") + return "\n".join(lines) + + +def update_body_with_changelog(old_body, new_sha): + """ + Smart body update: + - Keep existing content before the changelog marker + - Generate fresh changelog from git log + - Ensure version line reflects new_sha + """ + # Split body at changelog marker + if CHANGELOG_MARKER in old_body: + header_part = old_body.split(CHANGELOG_MARKER, 1)[0].rstrip() + else: + # No marker found: find ## Changelog section boundary + idx = old_body.find("## Changelog") + if idx != -1: + header_part = old_body[:idx].rstrip() + else: + header_part = old_body.rstrip() + + # Extract the last SHA already in the changelog (from original body) + last_sha = extract_last_changelog_sha(old_body) + + # Determine what new commits to include + if last_sha: + # There is an existing changelog — get only new commits since last entry + new_commits = git_log(last_sha, "HEAD") + if not new_commits: + print(" Already up-to-date (no new commits since last changelog entry).") + # Still rebuild all commits for consistent display + all_commits = git_log(None, "HEAD", MAX_CHANGELOG_ENTRIES) + else: + print(f" Found {len(new_commits)} new commit(s) since {last_sha}.") + # Rebuild full changelog: get all recent entries + all_commits = git_log(None, "HEAD", MAX_CHANGELOG_ENTRIES) + else: + # No existing changelog — grab recent history + all_commits = git_log(None, "HEAD", MAX_CHANGELOG_ENTRIES) + print(f" No existing changelog; building from last {len(all_commits)} commits.") + + # Build the changelog section + changelog_block = build_changelog_body(all_commits) + + # Update version line + version_line = f"**dae version**: `unstable-{new_sha}`" + + # Reconstruct body: header + version + blank + changelog + # Extract pure header (remove any existing version line) + header_clean = [] + for line in header_part.split("\n"): + if "dae version" in line.lower(): + continue + header_clean.append(line) + # Remove trailing empty lines + while header_clean and header_clean[-1] == "": + header_clean.pop() + + new_body_parts = [ + "\n".join(header_clean), + "", + version_line, + "", + CHANGELOG_MARKER.strip(), + changelog_block, + ] + new_body = "\n".join(new_body_parts) + return new_body + + def main(): if len(sys.argv) < 3: print("Usage: python3 update-release.py ") @@ -46,18 +164,19 @@ def main(): release_id = sys.argv[1] binary_path = sys.argv[2] + full_sha = os.environ.get("GITHUB_SHA", "") + short_sha = full_sha[:7] if full_sha else "" if not TOKEN: print("GITHUB_TOKEN not set!", file=sys.stderr) sys.exit(1) - print(f"Updating release {release_id} in {REPO}...") + print(f"Updating release {release_id} in {REPO} (SHA: {short_sha})...") # 1. Get release, find old asset release = api("GET", f"https://api.github.com/repos/{REPO}/releases/{release_id}") print(f" Release: {release['name']}") print(f" Tag: {release['tag_name']}") - print(f" HTML: {release['html_url']}") for asset in release.get("assets", []): if asset["name"] == "dae-linux-x86_64": @@ -84,21 +203,16 @@ def main(): result = json.loads(resp.read()) print(f" Uploaded: {result['name']} ({result['size']} bytes)") - # 3. Update release body with latest commit SHA - sha = os.environ.get("GITHUB_SHA", "")[:7] - body = release["body"] - version_line = f"**dae version**: `unstable-{sha}`" - if sha and sha not in body: - lines = body.split("\n") - # Insert version line after the first heading - new_lines = [lines[0], "", version_line, ""] - new_lines.extend(lines[1:]) - new_body = "\n".join(new_lines) + # 3. Update release body with changelog + old_body = release.get("body", "") or "" + new_body = update_body_with_changelog(old_body, short_sha) + + if new_body != old_body: api("PATCH", f"https://api.github.com/repos/{REPO}/releases/{release_id}", json.dumps({"body": new_body})) - print(f" Release body updated with {version_line}") + print(" Release body updated with fresh changelog.") else: - print(" Release body already up-to-date (commit SHA present).") + print(" Release body unchanged (already up-to-date).") print("Done!") From 5261544bd6fa0ed2116519c7305f3efa97d923d8 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:48:20 +0800 Subject: [PATCH 35/44] ci: add scripts/** to build trigger paths --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 593a461b7c..6a3710f1c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,7 @@ on: - "go.sum" - ".github/workflows/build.yml" - ".github/workflows/seed-build.yml" + - "scripts/**" - "Makefile" jobs: From 813cc24dd561a8ed8819ca47bd91b4889e2558dc Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:49:13 +0800 Subject: [PATCH 36/44] fix(ci): correct artifact extraction path (build/* prefix) --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a3710f1c8..2eb2f6a469 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,8 @@ jobs: - name: Extract dae binary run: | unzip -o /tmp/dae_artifact/dae-linux-x86_64.zip -d /tmp/dae_artifact/ - ls -lh /tmp/dae_artifact/dae-linux-x86_64 + ls -lh /tmp/dae_artifact/build/dae-linux-x86_64 + mv /tmp/dae_artifact/build/dae-linux-x86_64 /tmp/dae_artifact/dae-linux-x86_64 - name: Update GitHub Release (delete old + upload new + update body) env: From 43a377395f1ad902123086e0a20f0615a155d942 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:53:25 +0800 Subject: [PATCH 37/44] fix(ci): download-artifact auto-extracts, no unzip needed --- .github/workflows/build.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2eb2f6a469..c9b8b03ee5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,11 +71,10 @@ jobs: name: dae-linux-x86_64.zip path: /tmp/dae_artifact/ - - name: Extract dae binary + - name: Verify dae binary (auto-extracted by download-artifact) run: | - unzip -o /tmp/dae_artifact/dae-linux-x86_64.zip -d /tmp/dae_artifact/ - ls -lh /tmp/dae_artifact/build/dae-linux-x86_64 - mv /tmp/dae_artifact/build/dae-linux-x86_64 /tmp/dae_artifact/dae-linux-x86_64 + ls -lh /tmp/dae_artifact/dae-linux-x86_64 + chmod +x /tmp/dae_artifact/dae-linux-x86_64 - name: Update GitHub Release (delete old + upload new + update body) env: From b645d6eb479a946a851cc075492cd683670a7e51 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 10:56:09 +0800 Subject: [PATCH 38/44] fix(ci): add contents:write permission for release asset management --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9b8b03ee5..c49405146b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,8 @@ jobs: if: needs.build.result == 'success' needs: [build] runs-on: ubuntu-latest + permissions: + contents: write # Required for release asset upload/delete steps: - name: Checkout codebase uses: actions/checkout@v6 From bb353b43da55da8d4e4eeb23d7a31336525f1a07 Mon Sep 17 00:00:00 2001 From: LiYang Date: Sun, 14 Jun 2026 20:48:45 +0800 Subject: [PATCH 39/44] fix(gitignore): separate .sisyphus and dae-linux-* patterns --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b82f0498be..acee79420a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ node_modules/ venv CLAUDE.md AGENTS.md -.sisyphusdae-linux-* +.sisyphus +dae-linux-* From 3d17895c881ffb9e5b464123bac42b63b3f8aea6 Mon Sep 17 00:00:00 2001 From: olicesx Date: Thu, 18 Jun 2026 14:27:47 +0800 Subject: [PATCH 40/44] fix(reload): async core.Close in closeTail when BPF is ejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When config is unchanged and a reload is triggered, the staged handoff path shares BPF objects between old and new generations. The old generation's closeTail() calls core.Close() synchronously within a 5s timeout window. core.Close() runs TC filter detachment (netlink.FilterDel) which can exceed the timeout, causing "control plane close tail timed out" warnings (dae#1013). In staged handoff, by the time closeTail() runs, EjectBpf() has already transferred BPF ownership to the new generation. core.Close() only needs to detach TC filters and release the UDP tracker — neither is time-critical because the new generation already has its own TC filters attached. Run core.Close() in a background goroutine when isBpfEjected() is true. This check covers all staged handoff scenarios including the first reload (P0→P1) where sharedBpfReload is false but EjectBpf has been called. Using bpfEjected rather than sharedBpfReload ensures the async path is taken based on actual BPF ownership state, not creation history. This preserves zero-downtime staged handoff while eliminating the closeTail timeout. Reported-by: @itoywh See: dae#1013 --- control/control_plane.go | 37 ++++++++++++++++++++++++++++++++--- control/control_plane_core.go | 10 ++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/control/control_plane.go b/control/control_plane.go index 38827dfc6f..9a31e94db8 100644 --- a/control/control_plane.go +++ b/control/control_plane.go @@ -923,6 +923,16 @@ func (c *ControlPlane) PeekBpf() *bpfObjects { return c.core.PeekBpf() } +// isBpfEjected reports whether BPF ownership has been transferred to another +// generation via EjectBpf. Callers use this to decide whether core.Close() +// can run asynchronously (it is pure cleanup when BPF has been ejected). +func (c *ControlPlane) isBpfEjected() bool { + if c == nil || c.core == nil { + return false + } + return c.core.IsBpfEjected() +} + func (c *ControlPlane) ActiveSessionCount() int { if c == nil || c.drainTracker == nil { return 0 @@ -3578,10 +3588,31 @@ func (c *ControlPlane) closeTail() error { // Note: inConnections is cleared by AbortConnections() which should be called before Close() - // Combine defer errors with core.Close error + // Core cleanup. + // + // When BPF has been ejected to the new generation (staged handoff), + // core.Close() only needs to detach TC filters (netlink.FilterDel) and + // release the UDP conn-state tracker — none of which are time-critical + // because the new generation already has its own TC filters attached. + // Run it asynchronously to avoid the 5 s closeTail timeout that fires + // during staged handoff retirement (dae#1013). + // + // We check isBpfEjected() rather than sharedBpfReload because the + // initial control plane (P0) has sharedBpfReload=false even though + // EjectBpf() has been called during the first staged handoff. if c.core != nil { - if coreErr := c.core.Close(); coreErr != nil { - errs = append(errs, coreErr) + if c.isBpfEjected() { + core := c.core + log := c.log + go func() { + if err := core.Close(); err != nil && log != nil { + log.WithError(err).Warn("[Reload] Async core cleanup after staged handoff") + } + }() + } else { + if coreErr := c.core.Close(); coreErr != nil { + errs = append(errs, coreErr) + } } } diff --git a/control/control_plane_core.go b/control/control_plane_core.go index 9908b8a0ed..981d0b0a24 100644 --- a/control/control_plane_core.go +++ b/control/control_plane_core.go @@ -967,6 +967,16 @@ func (c *controlPlaneCore) ReleaseUdpConnStateTuples(keys []bpfTuplesKey) error return err } +// IsBpfEjected reports whether BPF ownership has been transferred to another +// generation via EjectBpf. When true, core.Close() is purely cleanup (TC +// filter detach, UDP tracker release) and can run asynchronously without +// risking the closeTail timeout. +func (c *controlPlaneCore) IsBpfEjected() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.bpfEjected +} + // EjectBpf will resect bpf from destroying life-cycle of control plane core. func (c *controlPlaneCore) EjectBpf() *bpfObjects { c.mu.Lock() From 2426766e0b578706c5fabb674d1162875bf70f38 Mon Sep 17 00:00:00 2001 From: LiYang Date: Thu, 18 Jun 2026 16:53:49 +0800 Subject: [PATCH 41/44] fix: gofmt cleanup, DRY refactor, and add FixedWithFallback tests - Run gofmt on dialer_selection_policy.go (struct field align, blank line) - Run gofmt on connectivity_check.go (latency field align) - DRY: collapse shouldSkipTcp6Probes + shouldSkipUdp6Probes into one shouldSkipIpFamily6 (identical logic, different config keys) - Add unit tests for FixedWithFallback policy: * TestDialerGroup_Select_FixedWithFallback_Alive * TestDialerGroup_Select_FixedWithFallback_DeadFallback * TestDialerGroup_Select_FixedWithFallback_ZeroRetriesNoFallback * TestDialerGroup_Select_FixedWithFallback_Recovery * TestDialerGroup_Select_FixedWithFallback_RetryTimeoutBehavior --- .../outbound/dialer/connectivity_check.go | 43 +----- component/outbound/dialer_group_test.go | 143 ++++++++++++++++++ component/outbound/dialer_selection_policy.go | 5 +- 3 files changed, 154 insertions(+), 37 deletions(-) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index 6be1bab64e..0a4ef6aff2 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -464,13 +464,13 @@ func getActiveDialerCount() int { return poolActiveCount } -// shouldSkipTcp6Probes returns true when tcp_check_url explicitly lists only IPv4 -// addresses (no explicit IPv6 entries). -// This avoids unnecessary IPv6 TCP probes when the user's network doesn't support IPv6. +// shouldSkipIpFamily6 returns true when raw explicitly lists only IPv4 addresses +// (no explicit IPv6 entries). This avoids unnecessary IPv6 probes when the user's +// network doesn't support IPv6. // Returns false (keep IPv6 probes) when: // - Explicit IPv6 addresses are found in config // - No explicit IPs are given (DNS resolution might return IPv6) -func shouldSkipTcp6Probes(raw []string) bool { +func shouldSkipIpFamily6(raw []string) bool { hasIpv6 := false hasExplicitIpv4 := false @@ -486,34 +486,7 @@ func shouldSkipTcp6Probes(raw []string) bool { } } - if hasIpv6 { - return false - } - return hasExplicitIpv4 -} - -// shouldSkipUdp6Probes returns true when udp_check_dns explicitly lists only IPv4 -// addresses (no explicit IPv6 entries). -func shouldSkipUdp6Probes(raw []string) bool { - hasIpv6 := false - hasExplicitIpv4 := false - - for i := 1; i < len(raw); i++ { - addr, err := netip.ParseAddr(raw[i]) - if err != nil { - continue - } - if addr.Is6() { - hasIpv6 = true - } else { - hasExplicitIpv4 = true - } - } - - if hasIpv6 { - return false - } - return hasExplicitIpv4 + return hasExplicitIpv4 && !hasIpv6 } // hasUdpDnsConfig returns true if udp_check_dns is configured. @@ -633,8 +606,8 @@ func (d *Dialer) aliveBackground() { // - Skip IPv6 probes when only IPv4 addresses are explicitly configured useTcpCheck := len(d.TcpCheckOptionRaw.Raw) > 0 useUdpDns := len(d.CheckDnsOptionRaw.Raw) > 0 - skipTcp6 := useTcpCheck && shouldSkipTcp6Probes(d.TcpCheckOptionRaw.Raw) - skipUdp6 := useUdpDns && shouldSkipUdp6Probes(d.CheckDnsOptionRaw.Raw) + skipTcp6 := useTcpCheck && shouldSkipIpFamily6(d.TcpCheckOptionRaw.Raw) + skipUdp6 := useUdpDns && shouldSkipIpFamily6(d.CheckDnsOptionRaw.Raw) var CheckOpts []*CheckOption if useTcpCheck { @@ -1139,7 +1112,7 @@ func (d *Dialer) markAvailable(typ *NetworkType, latency time.Duration) (collect d.Log.WithFields(logrus.Fields{ "dialer": nodeName, "network": typ.String(), - "latency": latency.String(), + "latency": latency.String(), }).Infoln("Node became ALIVE") } d.notifyAliveTransition(typ, true) diff --git a/component/outbound/dialer_group_test.go b/component/outbound/dialer_group_test.go index 871bb278c6..24a1ecbd3c 100644 --- a/component/outbound/dialer_group_test.go +++ b/component/outbound/dialer_group_test.go @@ -529,3 +529,146 @@ func TestDialerGroup_Select_DataUdpFixedPolicyDoesNotFallback(t *testing.T) { t.Fatalf("expected fixed policy to keep selecting dialers[1], got another dialer") } } + +func TestDialerGroup_Select_FixedWithFallback_Alive(t *testing.T) { + g, dialers := newTestGroupForSelection(DialerSelectionPolicy{ + Policy: consts.DialerSelectionPolicy_FixedWithFallback, + FixedIndex: 1, + FixedFallbackTimeout: 3 * time.Second, + FixedFallbackRetries: 3, + FallbackPolicy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, + }) + + // Mark all dialers alive. + for _, d := range dialers { + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(d, true) + } + + // When fixed dialer is alive, it should always be selected. + for i := 0; i < 10; i++ { + selected, _, err := g.Select(TestNetworkType, false) + if err != nil { + t.Fatalf("Select() error = %v", err) + } + if selected != dialers[1] { + t.Fatalf("[%d] expected dialers[1] (alive fixed), got %v", i, selected) + } + } +} + +func TestDialerGroup_Select_FixedWithFallback_DeadFallback(t *testing.T) { + g, dialers := newTestGroupForSelection(DialerSelectionPolicy{ + Policy: consts.DialerSelectionPolicy_FixedWithFallback, + FixedIndex: 1, + FixedFallbackTimeout: 1 * time.Second, + FixedFallbackRetries: 1, + FallbackPolicy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, + }) + + // Mark all dialers dead. + for _, d := range dialers { + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(d, false) + } + + // First select: fixed dialer dead → start retry (retries exhausted immediately since retries=1) + // After retries exhausted, fallback kicks in. + selected, _, err := g.Select(TestNetworkType, false) + if err != nil { + t.Fatalf("Select() error = %v", err) + } + if selected == dialers[1] { + t.Fatalf("expected fallback (fixed dialer retries exhausted), got fixed dialer") + } +} + +func TestDialerGroup_Select_FixedWithFallback_ZeroRetriesNoFallback(t *testing.T) { + g, dialers := newTestGroupForSelection(DialerSelectionPolicy{ + Policy: consts.DialerSelectionPolicy_FixedWithFallback, + FixedIndex: 1, + FixedFallbackTimeout: 3 * time.Second, + FixedFallbackRetries: 0, // No retries allowed + FallbackPolicy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, + }) + + // Mark fixed dialer dead, others alive. + dialers[0].MustGetLatencies10(TestNetworkType).AppendLatency(100 * time.Millisecond) + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(dialers[0], true) + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(dialers[1], false) + + // With retries=0, first dead detection immediately falls back. + selected, _, err := g.Select(TestNetworkType, false) + if err != nil { + t.Fatalf("Select() error = %v", err) + } + // Should fall back to dialers[0] (min_moving_avg). + if selected == dialers[1] { + t.Fatalf("expected fallback to dialers[0], still got fixed dialer") + } +} + +func TestDialerGroup_Select_FixedWithFallback_Recovery(t *testing.T) { + g, dialers := newTestGroupForSelection(DialerSelectionPolicy{ + Policy: consts.DialerSelectionPolicy_FixedWithFallback, + FixedIndex: 1, + FixedFallbackTimeout: 1 * time.Second, + FixedFallbackRetries: 1, + FallbackPolicy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, + }) + + // Mark fixed dialer dead, others alive so fallback works. + dialers[0].MustGetLatencies10(TestNetworkType).AppendLatency(100 * time.Millisecond) + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(dialers[0], true) + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(dialers[1], false) + + // Exhaust retries so we're in fallback. + g.Select(TestNetworkType, false) + + // Now fixed dialer revives. + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(dialers[1], true) + + // Recovery: traffic should return to fixed dialer immediately. + selected, _, err := g.Select(TestNetworkType, false) + if err != nil { + t.Fatalf("Select() error = %v", err) + } + if selected != dialers[1] { + t.Fatalf("expected fixed dialer after recovery, got %v", selected) + } +} + +func TestDialerGroup_Select_FixedWithFallback_RetryTimeoutBehavior(t *testing.T) { + g, dialers := newTestGroupForSelection(DialerSelectionPolicy{ + Policy: consts.DialerSelectionPolicy_FixedWithFallback, + FixedIndex: 1, + FixedFallbackTimeout: 2 * time.Second, + FixedFallbackRetries: 3, + FallbackPolicy: consts.DialerSelectionPolicy_MinMovingAverageLatencies, + }) + + // Fixed dialer dead; others alive for fallback. + dialers[0].MustGetLatencies10(TestNetworkType).AppendLatency(50 * time.Millisecond) + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(dialers[0], true) + g.MustGetAliveDialerSet(TestNetworkType).NotifyLatencyChange(dialers[1], false) + + // First call: deadSince set, retry 0. Fixed dialer returned. + d, _, _ := g.Select(TestNetworkType, false) + if d != dialers[1] { + t.Fatalf("first select: expected fixed (dead detected), got %v", d) + } + + // Call within timeout window: still return fixed. + time.Sleep(500 * time.Millisecond) + d, _, _ = g.Select(TestNetworkType, false) + if d != dialers[1] { + t.Fatalf("select within timeout: expected fixed, got %v", d) + } + + // After timeout + retries exhausted, fallback to dialers[0]. + // retries=1 at first timeout, retries=2 at second, retries=3 at third → exhausted. + // Wait long enough for 3 timeouts to pass. + time.Sleep(7 * time.Second) + d, _, _ = g.Select(TestNetworkType, false) + if d == dialers[1] { + t.Fatalf("after retries exhausted: expected fallback, still got fixed dialer") + } +} diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index d5213a8250..3c400c29e7 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -18,8 +18,8 @@ import ( type DialerSelectionPolicy struct { Policy consts.DialerSelectionPolicy FixedIndex int - FixedFallbackTimeout time.Duration // 节点超时时间 - FixedFallbackRetries int // 超时重试次数 + FixedFallbackTimeout time.Duration // 节点超时时间 + FixedFallbackRetries int // 超时重试次数 FallbackPolicy consts.DialerSelectionPolicy // 重试耗尽后的回退策略,默认 min_moving_avg } @@ -143,6 +143,7 @@ func parsePolicyName(s string) (consts.DialerSelectionPolicy, error) { return consts.DialerSelectionPolicy(0), fmt.Errorf("unsupported fallback policy %q (supported: random, min_moving_avg, min, min_avg10)", s) } } + // Supported: "ms" (milliseconds), "s" (seconds), "m" (minutes). // No suffix defaults to seconds for backward compatibility. // Examples: "500ms", "5s", "2m", "10". From 6bab3abe6fb85aa1a7ef255108afa57ccde32dff Mon Sep 17 00:00:00 2001 From: LiYang Date: Thu, 18 Jun 2026 16:58:03 +0800 Subject: [PATCH 42/44] fix(dialer_selection_policy): correct error message to 1-4 params --- component/outbound/dialer_selection_policy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/component/outbound/dialer_selection_policy.go b/component/outbound/dialer_selection_policy.go index 3c400c29e7..50c2a194f8 100644 --- a/component/outbound/dialer_selection_policy.go +++ b/component/outbound/dialer_selection_policy.go @@ -64,7 +64,7 @@ func NewDialerSelectionPolicyFromGroupParam(param *config.Group) (policy *Dialer return nil, fmt.Errorf("policy param does not support not operator: !%v()", f.Name) } if len(f.Params) < 1 || len(f.Params) > 4 { - return nil, fmt.Errorf(`invalid "%v" param format: expected 1-3 params, got %v`, f.Name, len(f.Params)) + return nil, fmt.Errorf(`invalid "%v" param format: expected 1-4 params, got %v`, f.Name, len(f.Params)) } // Parse index (required, first param) if f.Params[0].Key != "" { From d0f8f98e0d2506e438e71e4d7f18c5bf45f1a91e Mon Sep 17 00:00:00 2001 From: LiYang Date: Thu, 18 Jun 2026 23:46:19 +0800 Subject: [PATCH 43/44] optimize: skip IP family probe when only one family is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename shouldSkipIpFamily6 → shouldSkipIpFamily (returns '4'/'6'/'' instead of bool) - Add skipTcp4/skipUdp4 to complement existing skipTcp6/skipUdp6 - When tcp_check_url has only IPv6 addr, only probe tcp6 (skip useless tcp4) - When udp_check_dns has only IPv6 addr, only probe udp6_dns (skip useless udp4) - Same logic applies symmetrically for IPv4-only configs --- .../outbound/dialer/connectivity_check.go | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index 0a4ef6aff2..1153b11445 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -470,9 +470,11 @@ func getActiveDialerCount() int { // Returns false (keep IPv6 probes) when: // - Explicit IPv6 addresses are found in config // - No explicit IPs are given (DNS resolution might return IPv6) -func shouldSkipIpFamily6(raw []string) bool { +// shouldSkipIpFamily returns which IP family to skip based on configured addresses. +// Returns "" (skip none), "4" (skip IPv4), or "6" (skip IPv6). +func shouldSkipIpFamily(raw []string) string { hasIpv6 := false - hasExplicitIpv4 := false + hasIpv4 := false for i := 1; i < len(raw); i++ { addr, err := netip.ParseAddr(raw[i]) @@ -482,11 +484,17 @@ func shouldSkipIpFamily6(raw []string) bool { if addr.Is6() { hasIpv6 = true } else { - hasExplicitIpv4 = true + hasIpv4 = true } } - return hasExplicitIpv4 && !hasIpv6 + if hasIpv4 && !hasIpv6 { + return "6" + } + if hasIpv6 && !hasIpv4 { + return "4" + } + return "" } // hasUdpDnsConfig returns true if udp_check_dns is configured. @@ -606,18 +614,24 @@ func (d *Dialer) aliveBackground() { // - Skip IPv6 probes when only IPv4 addresses are explicitly configured useTcpCheck := len(d.TcpCheckOptionRaw.Raw) > 0 useUdpDns := len(d.CheckDnsOptionRaw.Raw) > 0 - skipTcp6 := useTcpCheck && shouldSkipIpFamily6(d.TcpCheckOptionRaw.Raw) - skipUdp6 := useUdpDns && shouldSkipIpFamily6(d.CheckDnsOptionRaw.Raw) + skipTcp4 := useTcpCheck && shouldSkipIpFamily(d.TcpCheckOptionRaw.Raw) == "4" + skipTcp6 := useTcpCheck && shouldSkipIpFamily(d.TcpCheckOptionRaw.Raw) == "6" + skipUdp4 := useUdpDns && shouldSkipIpFamily(d.CheckDnsOptionRaw.Raw) == "4" + skipUdp6 := useUdpDns && shouldSkipIpFamily(d.CheckDnsOptionRaw.Raw) == "6" var CheckOpts []*CheckOption if useTcpCheck { - CheckOpts = append(CheckOpts, tcp4CheckOpt) + if !skipTcp4 { + CheckOpts = append(CheckOpts, tcp4CheckOpt) + } if !skipTcp6 { CheckOpts = append(CheckOpts, tcp6CheckOpt) } } if useUdpDns { - CheckOpts = append(CheckOpts, udp4CheckDnsOpt) + if !skipUdp4 { + CheckOpts = append(CheckOpts, udp4CheckDnsOpt) + } if !skipUdp6 { CheckOpts = append(CheckOpts, udp6CheckDnsOpt) } @@ -633,9 +647,9 @@ func (d *Dialer) aliveBackground() { if d.Log.IsLevelEnabled(logrus.DebugLevel) { d.Log.WithFields(logrus.Fields{ "dialer": d.property.Name, - "tcp4": useTcpCheck, + "tcp4": useTcpCheck && !skipTcp4, "tcp6": useTcpCheck && !skipTcp6, - "udp4_dns": useUdpDns, + "udp4_dns": useUdpDns && !skipUdp4, "udp6_dns": useUdpDns && !skipUdp6, }).Debugln("Connectivity check probes configured") } From 57d32792545c5b9242d76f81261810c885360871 Mon Sep 17 00:00:00 2001 From: LiYang Date: Fri, 19 Jun 2026 00:00:17 +0800 Subject: [PATCH 44/44] feat: upgrade health-check disabled messages from Debug to Warn level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - connectivity_check.go: Debugln → Warnln for check_interval=0 and no checks configured (production-critical, users must see this) - control_plane.go: add global WARN at startup if all three health check config fields (check_interval, tcp_check_url, udp_check_dns) are unset --- component/outbound/dialer/connectivity_check.go | 4 ++-- control/control_plane.go | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/component/outbound/dialer/connectivity_check.go b/component/outbound/dialer/connectivity_check.go index 1153b11445..52f8b33a26 100644 --- a/component/outbound/dialer/connectivity_check.go +++ b/component/outbound/dialer/connectivity_check.go @@ -506,7 +506,7 @@ func (d *Dialer) aliveBackground() { // If check_interval is 0 or not configured, skip connectivity check entirely if d.CheckInterval == 0 { d.Log.WithField("dialer", d.Property().Name). - Debugln("Connectivity check disabled (check_interval=0)") + Warnln("Connectivity check disabled (check_interval=0)") return } @@ -640,7 +640,7 @@ func (d *Dialer) aliveBackground() { // If neither TCP nor UDP checks are configured, return early if len(CheckOpts) == 0 { d.Log.WithField("dialer", d.Property().Name). - Debugln("No connectivity checks configured, skipping") + Warnln("No connectivity checks configured, skipping") return } diff --git a/control/control_plane.go b/control/control_plane.go index 9a31e94db8..82ba893ed2 100644 --- a/control/control_plane.go +++ b/control/control_plane.go @@ -524,6 +524,12 @@ func newControlPlaneWithContextOptions( log.Warnln("AllowInsecure is enabled, but it is not recommended. Please make sure you have to turn it on.") } locationFinder := assets.NewLocationFinder(externGeoDataDirs) + + // Warn if health check is implicitly disabled (no explicit config). + if global.CheckInterval == 0 && len(global.TcpCheckUrl) == 0 && len(global.UdpCheckDns) == 0 { + log.Warnln("Health check is DISABLED: check_interval, tcp_check_url, and udp_check_dns are all not explicitly configured. " + + "Nodes will not be probed. Set check_interval and configure tcp_check_url/udp_check_dns to enable.") + } option := dialer.NewGlobalOption(global, log) option.DaeDNS, err = daedns.NewWithOption(log, global, dnsConfig, &daedns.NewOption{LocationFinder: locationFinder}) if err != nil {