A node reached only as a chain hop was being measured twice, and the reading the panel showed was the wrong one. On a router in Russia that is not a cosmetic difference: a node the chain carries fine behind a WireGuard hop is dead when dialled straight out of the WAN, so the group card read "0 of 2 alive" while that very group was carrying every packet. Two dial paths existed outside the observatory plan. URLTestGroup.PostStart warmed up every urltest group at box start whether or not any rule reached it, and the panel's Test button reached URLTest.DialContext, whose first act is Touch() — arming a ticker that re-swept those groups directly every probe interval for the next thirty minutes. Both wrote under the BASE node tag, and both dialled the base outbound, which carries no chain detour at all. The observatory was never the liar: its plan roots come from the rules, and a chain hop copy is stored only under its own tag, so no plan job could ever write under a base tag. The fix is therefore to remove the other two paths, not to touch the plan. TestGroups now asks the observatory for an out-of-turn pass and reports what it measured; a target no enabled rule routes to is not dialled at all and says so. Unused urltest groups stand down their own self-check via a new SelfCheck option (nil keeps today's behaviour, so every existing config is unchanged). The one direct dial left is the exit-address lookup, which has no other possible source — it now runs only for a target that is both routed and already read alive, so it travels the routed path and never touches an unused group. Chain hop wrappers are probed as measurements of their own and surfaced as chains[].hops[], because "which hop is dead" is the question an operator has and the chain-level verdict cannot answer it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
68 lines
4.1 KiB
Go
68 lines
4.1 KiB
Go
package option
|
|
|
|
import "github.com/sagernet/sing/common/json/badoption"
|
|
|
|
type SelectorOutboundOptions struct {
|
|
Outbounds []string `json:"outbounds"`
|
|
Default string `json:"default,omitempty"`
|
|
InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"`
|
|
}
|
|
|
|
type URLTestOutboundOptions struct {
|
|
Outbounds []string `json:"outbounds"`
|
|
URL string `json:"url,omitempty"`
|
|
Interval badoption.Duration `json:"interval,omitempty"`
|
|
Tolerance uint16 `json:"tolerance,omitempty"`
|
|
IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"`
|
|
InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"`
|
|
// lx: SPEC 019 v2 — load-balancing.
|
|
Mode string `json:"mode,omitempty"` // least_test (default) | round_robin
|
|
Balancer *URLTestBalancerOptions `json:"balancer,omitempty"`
|
|
// lx: health board §5.C — SelfCheck stands the group's OWN background
|
|
// health-check up or down. nil/absent == true, so every existing config keeps
|
|
// today's behaviour.
|
|
//
|
|
// Why this exists at all: a urltest group probes its members BY ITSELF — a
|
|
// warm-up sweep at PostStart and a ticker for as long as traffic keeps
|
|
// touching it — and it dials the members' outbounds DIRECTLY, from the
|
|
// router, over whatever the default WAN route is. For a group that traffic
|
|
// actually flows through, that is exactly right: the probe travels the same
|
|
// path the connections do. But for a group NO routing rule reaches, that
|
|
// same probe measures a path nothing uses — and it stores the result under
|
|
// the members' BASE tags, which every health consumer then reads as "the
|
|
// node's health". A node that is blocked on the direct WAN and perfectly
|
|
// alive behind a tunnel therefore reads "dead" the moment such a group
|
|
// probes it; the reading is not merely stale, it is FALSE, and it poisons
|
|
// the shared board for everyone (selection, the panel, the observatory's
|
|
// freshness gate). SelfCheck=false is how the control plane stands such a
|
|
// group's own schedule down: the shater engine computes which groups the
|
|
// applied rules actually reach (the observatory's used-set) and disables
|
|
// the self-check on the rest, so the ONLY prober left is the observatory —
|
|
// which probes along the real dial paths and nothing else.
|
|
//
|
|
// The flag suppresses only the group's own SCHEDULE (the PostStart warm-up
|
|
// and the Touch ticker). An EXPLICIT CheckOutbounds/URLTest call — the
|
|
// adapter interface a human or an API invokes on purpose — still works.
|
|
SelfCheck *bool `json:"self_check,omitempty"`
|
|
}
|
|
|
|
// URLTestBalancerOptions configures round_robin: a fixed-size pool of live nodes, lazily
|
|
// health-checked, with optional per-flow stickiness. lx: SPEC 019 v2. Required only for
|
|
// round_robin (defaults {pool:3, pool_tolerance:0} apply when omitted); set with any other
|
|
// mode is an error.
|
|
type URLTestBalancerOptions struct {
|
|
Pool int `json:"pool,omitempty"` // rotation pool size, default 3, < 1 is an error
|
|
PoolTolerance uint16 `json:"pool_tolerance,omitempty"` // ms; 0 = first-live-fill, > 0 = top-N-by-delay with eviction threshold
|
|
// Priority makes the CONFIG ORDER the ranking: the pool always holds the first `pool` LIVE
|
|
// nodes in configured order, re-derived every health-check tick. Unlike the first-live path,
|
|
// a higher-priority node that comes back to life re-takes its slot even while a lower-priority
|
|
// one is still live — this is the fail-back the `failover` strategy relies on. Meaningful only
|
|
// with mode round_robin; failover pairs it with pool 1 + sticky off (see failoverBalancer).
|
|
Priority bool `json:"priority,omitempty"`
|
|
// StickyHash key components: process|domain|source_ip|dest_ip|dest_port.
|
|
// Omitted → default ["process","domain"]. To DISABLE stickiness use ["none"] — a bare [] is
|
|
// NOT honoured because the config decoder (badjson.UnmarshallExcludedContext) re-marshals
|
|
// the struct and collapses an empty array to nil, indistinguishable from "omitted".
|
|
StickyHash []string `json:"sticky_hash,omitempty"`
|
|
}
|