Skip to content

Commit 743b82f

Browse files
committed
Merge dev into main: release 3.2.2
2 parents f349849 + 89158d6 commit 743b82f

6 files changed

Lines changed: 219 additions & 93 deletions

File tree

frontend/src/hooks/useDaemonStatus.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,18 @@ export const useDaemonStatus = (
163163
setFailedProxy(null);
164164
}
165165

166-
if (establishing) {
167-
setIsConnecting(false);
166+
// Keep the spinner up while the backend is in the establishing
167+
// phase: engine has booted but runPostStartProbe is still verifying
168+
// end-to-end traffic (1-3s for WG/Hysteria/VLESS handshakes). The
169+
// earlier code flipped this off here, which combined with the
170+
// sessionActive→isConnected mapping below made the UI lie green
171+
// a couple of seconds before the backend's "Подключено" log.
172+
//
173+
// Skip when a user-initiated control op is in flight — in that
174+
// case useDaemonControl owns the spinner and we'd otherwise stomp
175+
// its setIsConnecting(false) right after a disconnect.
176+
if (establishing && !isSwitchingRef.current) {
177+
setIsConnecting(true);
168178
}
169179

170180
const killSwitchTriggered =
@@ -233,7 +243,14 @@ export const useDaemonStatus = (
233243

234244
const allowStatus = !isSwitchingRef.current || establishing;
235245
if (allowStatus) {
236-
setIsConnected(sessionActive);
246+
// STRICT: only the backend's "fully connected" flag drives the
247+
// green/Connected indicator. Treating "establishing" as
248+
// "connected" caused the UI to claim success 1-3s before the
249+
// backend's post-start probe finished — which for WG/AWG (where
250+
// probe time is longest and traffic can still collapse after
251+
// handshake) meant the user saw "Connected" right before the
252+
// tunnel died. Spinner stays via setIsConnecting(true) above.
253+
setIsConnected(connected);
237254
resolveActiveProxy(data);
238255
}
239256

internal/proxy/engine.go

Lines changed: 119 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,49 @@ func BuildProxyModeConfig(cfg EngineConfig) (SingBoxConfig, error) {
434434
return sbCfg, nil
435435
}
436436

437+
// systemHasIPv6 reports whether the host has IPv6 wired up at the OS level.
438+
// We treat "any non-loopback, non-tunnel interface has at least one IPv6
439+
// unicast address" as the signal. This catches both adapter-level disabled
440+
// IPv6 and OS-wide DisabledComponents on Windows: with neither in play, every
441+
// box has at least a link-local fe80:: on the LAN adapter, which is enough
442+
// for sing-tun's CreateUnicastIpAddressEntry call to succeed against the TUN.
443+
//
444+
// Conservative-fail: on enumeration error we assume IPv6 is present, so we
445+
// don't silently strip IPv6 from the tunnel on hosts where the check is
446+
// merely flaky (CGO timeout, etc.).
447+
func systemHasIPv6() bool {
448+
ifaces, err := net.Interfaces()
449+
if err != nil {
450+
return true
451+
}
452+
for _, ifi := range ifaces {
453+
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 {
454+
continue
455+
}
456+
if looksLikeTunnelInterface(ifi.Name) {
457+
continue
458+
}
459+
addrs, err := ifi.Addrs()
460+
if err != nil {
461+
continue
462+
}
463+
for _, a := range addrs {
464+
var ip net.IP
465+
switch v := a.(type) {
466+
case *net.IPNet:
467+
ip = v.IP
468+
case *net.IPAddr:
469+
ip = v.IP
470+
}
471+
if ip == nil || ip.To4() != nil {
472+
continue
473+
}
474+
return true
475+
}
476+
}
477+
return false
478+
}
479+
437480
func BuildTunnelModeConfig(cfg EngineConfig) (SingBoxConfig, error) {
438481
tunIPv4 := "172.19.0.1/30"
439482
if cfg.TunIPv4 != "" {
@@ -443,11 +486,25 @@ func BuildTunnelModeConfig(cfg EngineConfig) (SingBoxConfig, error) {
443486
// an IPv6 address here, strict_route's WFP filters would silently
444487
// blackhole IPv6 — leaving the user without IPv6 connectivity while
445488
// connected.
489+
//
490+
// But: on Windows boxes where the IPv6 stack is disabled at the adapter
491+
// level (or globally via DisabledComponents), sing-tun's attempt to set
492+
// the IPv6 address on the TUN interface fails with
493+
// "configure tun interface: set ipv6 address: Element not found",
494+
// which ClassifyEngineStartError currently maps to "tun_privileges" and
495+
// the UI surfaces as "нужны права администратора" — sending users on a
496+
// fruitless quest to elevate. Only attach the IPv6 address when the
497+
// host actually exposes IPv6 on at least one non-loopback interface,
498+
// or when the user explicitly set TunIPv6 (override = "I know what I'm
499+
// doing").
446500
tunIPv6 := "fdfe:dcba:9876::1/126"
447501
if cfg.TunIPv6 != "" {
448502
tunIPv6 = cfg.TunIPv6
449503
}
450-
tunAddresses := []string{tunIPv4, tunIPv6}
504+
tunAddresses := []string{tunIPv4}
505+
if cfg.TunIPv6 != "" || systemHasIPv6() {
506+
tunAddresses = append(tunAddresses, tunIPv6)
507+
}
451508
tunStack := effectiveTunStack(cfg.TunStack)
452509
// strict_route adds WFP filters on Windows that drop outbound packets
453510
// bypassing the TUN. This is the only reliable way to stop Smart
@@ -476,21 +533,32 @@ func BuildTunnelModeConfig(cfg EngineConfig) (SingBoxConfig, error) {
476533
if err != nil {
477534
return SingBoxConfig{}, err
478535
}
536+
// UDPTimeout / EndpointIndependentNat are TUN-inbound NAT knobs aimed at
537+
// cleaning up dead UDP flows under DPI-driven QUIC retry storms. They
538+
// must NOT be applied when the active protocol is a WireGuard endpoint:
539+
// for WG/AWG the TUN inbound feeds packets straight into the endpoint,
540+
// which maintains its own session state, and forcing the inbound to
541+
// expire NAT slots after 30s tore down live tunnel traffic (handshake
542+
// passes, browser works for ~30s, then every UDP flow inside the tunnel
543+
// collapses). Keep inbound defaults (5min, symmetric) for endpoint protos.
544+
tun := SBInbound{
545+
Type: "tun",
546+
Tag: "tun-in",
547+
Address: tunAddresses,
548+
Stack: tunStack,
549+
AutoRoute: true,
550+
StrictRoute: strictRoute,
551+
RouteExcludeAddress: routeExclude,
552+
}
553+
if pt != "WIREGUARD" && pt != "AMNEZIAWG" {
554+
tun.UDPTimeout = "30s"
555+
tun.EndpointIndependentNat = true
556+
}
479557
sbCfg := SingBoxConfig{
480-
Log: &SBLog{Level: "error", Disabled: false},
481-
DNS: buildDNS(cfg),
482-
Endpoints: endpoints,
483-
Inbounds: []SBInbound{{
484-
Type: "tun",
485-
Tag: "tun-in",
486-
Address: tunAddresses,
487-
Stack: tunStack,
488-
AutoRoute: true,
489-
StrictRoute: strictRoute,
490-
RouteExcludeAddress: routeExclude,
491-
UDPTimeout: "30s",
492-
EndpointIndependentNat: true,
493-
}},
558+
Log: &SBLog{Level: "error", Disabled: false},
559+
DNS: buildDNS(cfg),
560+
Endpoints: endpoints,
561+
Inbounds: []SBInbound{tun},
494562
Outbounds: appendOutbounds(outbounds, cfg),
495563
Route: buildRoute(cfg),
496564
Experimental: buildExperimentalCache(dd),
@@ -538,14 +606,18 @@ func buildOutbounds(proxy ProxyConfig) []SBOutbound {
538606

539607
func buildDNS(cfg EngineConfig) *SBDNS {
540608
if cfg.Mode == ProxyModeTunnel {
541-
542-
pt := strings.ToUpper(strings.TrimSpace(cfg.Proxy.Type))
543-
isEndpoint := pt == "WIREGUARD" || pt == "AMNEZIAWG"
544-
609+
// All DNS servers route through the proxy/endpoint outbound (tag
610+
// "proxy" — same tag for SS/VLESS outbounds and for the WG/AWG
611+
// endpoint, see buildEndpoints). Earlier code set detour="" for
612+
// WG/AWG endpoints, relying on the peer's own DNS, but sing-box
613+
// then sent UDP/53 to 8.8.8.8 via the direct outbound. With
614+
// DNSLeakProtection (= strict_route) on, sing-tun's WFP filters
615+
// dropped those direct packets — DNS for the post-start HTTP probe
616+
// never resolved, the probe timed out, and Connect hung at
617+
// "Подключение..." until the daemon RPC ctx expired (~70s) and
618+
// reported "cancelled". Pinning detour to "proxy" sends DNS through
619+
// the tunnel for all protocols, eliminating the WFP race.
545620
detour := "proxy"
546-
if isEndpoint {
547-
detour = ""
548-
}
549621

550622
servers := []SBDNSServer{}
551623
if len(cfg.DNSServers) > 0 {
@@ -554,12 +626,8 @@ func buildDNS(cfg EngineConfig) *SBDNS {
554626
if server == "" {
555627
continue
556628
}
557-
srvType := "udp"
558-
if detour != "" {
559-
srvType = "tcp"
560-
}
561629
servers = append(servers, SBDNSServer{
562-
Type: srvType,
630+
Type: "tcp",
563631
Tag: fmt.Sprintf("custom-%d", i+1),
564632
Server: server,
565633
ServerPort: port,
@@ -568,21 +636,12 @@ func buildDNS(cfg EngineConfig) *SBDNS {
568636
}
569637
servers = append(servers, SBDNSServer{Type: "local", Tag: "local"})
570638
} else {
571-
if detour != "" {
572-
servers = []SBDNSServer{
573-
{Type: "tcp", Tag: "google-tcp", Server: "8.8.8.8", Detour: detour},
574-
{Type: "tcp", Tag: "cloudflare-tcp", Server: "1.1.1.1", Detour: detour},
575-
{Type: "tls", Tag: "google-tls", Server: "8.8.8.8", Detour: detour},
576-
{Type: "tls", Tag: "cloudflare-tls", Server: "1.1.1.1", Detour: detour},
577-
{Type: "local", Tag: "local"},
578-
}
579-
} else {
580-
servers = []SBDNSServer{
581-
{Type: "udp", Tag: "udp", Server: "8.8.8.8", Detour: detour},
582-
{Type: "tls", Tag: "google", Server: "8.8.8.8", Detour: detour},
583-
{Type: "tls", Tag: "cloudflare", Server: "1.1.1.1", Detour: detour},
584-
{Type: "local", Tag: "local"},
585-
}
639+
servers = []SBDNSServer{
640+
{Type: "tcp", Tag: "google-tcp", Server: "8.8.8.8", Detour: detour},
641+
{Type: "tcp", Tag: "cloudflare-tcp", Server: "1.1.1.1", Detour: detour},
642+
{Type: "tls", Tag: "google-tls", Server: "8.8.8.8", Detour: detour},
643+
{Type: "tls", Tag: "cloudflare-tls", Server: "1.1.1.1", Detour: detour},
644+
{Type: "local", Tag: "local"},
586645
}
587646
}
588647

@@ -592,7 +651,10 @@ func buildDNS(cfg EngineConfig) *SBDNS {
592651

593652
dns.Strategy = "ipv4_only"
594653

595-
if detour != "" && cfg.Proxy.IP != "" && net.ParseIP(cfg.Proxy.IP) == nil {
654+
// If the user gave a hostname for the proxy/endpoint server (not a
655+
// literal IP), resolve it via the local OS resolver — the proxy
656+
// detour can't be used to resolve its own server's hostname.
657+
if cfg.Proxy.IP != "" && net.ParseIP(cfg.Proxy.IP) == nil {
596658
dns.Rules = append(dns.Rules, SBDNSRule{
597659
Domain: []string{cfg.Proxy.IP},
598660
Server: "local",
@@ -739,20 +801,27 @@ func buildRoute(cfg EngineConfig) *SBRoute {
739801

740802
rules = appendAdBlockRouteRules(cfg, rules)
741803

742-
isEndpointProtocol := strings.EqualFold(strings.TrimSpace(cfg.Proxy.Type), "wireguard") ||
743-
strings.EqualFold(strings.TrimSpace(cfg.Proxy.Type), "amneziawg")
744-
if cfg.Mode == ProxyModeTunnel && !isEndpointProtocol {
745-
// Probe domains must go through the proxy outbound, even when issued
746-
// from the app's own process. Without this, the self-direct rule below
747-
// would route the post-start HTTP probe out via direct, masking a broken
748-
// SS/VLESS/VMESS tunnel as healthy.
804+
if cfg.Mode == ProxyModeTunnel {
805+
// Probe domains must go through the proxy/endpoint outbound, even when
806+
// issued from the app's own process. Without this, the self-direct rule
807+
// below would route the post-start HTTP probe out via direct, masking a
808+
// broken tunnel as healthy. The endpoint tag for WG/AWG is also "proxy"
809+
// (see buildEndpoints), so the same rule routes probes through the
810+
// WireGuard/AmneziaWG endpoint as well.
749811
if len(tunnelProbeDomains) > 0 {
750812
rules = append(rules, SBRouteRule{
751813
Action: "route",
752814
Domain: append([]string(nil), tunnelProbeDomains...),
753815
Outbound: "proxy",
754816
})
755817
}
818+
// Self-direct: keep our own process's non-probe traffic (updater,
819+
// telemetry, internal HTTP) out of the tunnel. Without this, sing-box's
820+
// auto_route pulls every socket of the host process into the TUN, and
821+
// for WG/AWG the post-start HTTP probe to gstatic/msftconnecttest/
822+
// cloudflare races against Windows' multi-homed DNS — the lookups can
823+
// escape via the LAN adapter and get dropped by strict_route's WFP
824+
// rules, so the probe times out even though the tunnel is healthy.
756825
if exe, err := os.Executable(); err == nil {
757826
if base := filepath.Base(exe); base != "" && base != "." {
758827
rx := `(?i)(^|[\\/])` + regexp.QuoteMeta(base) + `$`

internal/proxy/engine_route_test.go

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -145,35 +145,54 @@ func TestBuildRoute_TunnelMode_ProbeDomainsRoutedThroughProxyBeforeSelfDirect(t
145145
}
146146
}
147147

148-
func TestBuildRoute_TunnelMode_WireGuardDoesNotIncludeSelfDirectRule(t *testing.T) {
148+
// WG/AWG share the "proxy" tag with regular outbounds (see buildEndpoints),
149+
// so the same probe-domain → proxy and self-direct rules must apply for them
150+
// too: the post-start HTTP probe runs from our own process, and without these
151+
// rules it would either escape through direct (false success) or race with
152+
// strict_route's WFP filters (false failure) — the latter is what broke
153+
// AmneziaWG connect in 3.2.1.
154+
func TestBuildRoute_TunnelMode_WireGuardIncludesProbeAndSelfDirectRules(t *testing.T) {
155+
assertProbeAndSelfDirectRulesPresent(t, "wireguard")
156+
}
157+
158+
func TestBuildRoute_TunnelMode_AmneziaWGIncludesProbeAndSelfDirectRules(t *testing.T) {
159+
assertProbeAndSelfDirectRulesPresent(t, "amneziawg")
160+
}
161+
162+
func assertProbeAndSelfDirectRulesPresent(t *testing.T, proxyType string) {
163+
t.Helper()
149164
cfg := EngineConfig{
150165
Mode: ProxyModeTunnel,
151-
Proxy: ProxyConfig{Type: "wireguard"},
166+
Proxy: ProxyConfig{Type: proxyType},
152167
}
153168
route := buildRoute(cfg)
154169
if route == nil {
155170
t.Fatal("expected non-nil route")
156171
}
157-
for _, r := range route.Rules {
172+
173+
probeIdx := -1
174+
selfDirectIdx := -1
175+
for i, r := range route.Rules {
176+
if r.Outbound == "proxy" && len(r.Domain) > 0 {
177+
for _, d := range r.Domain {
178+
if d == "connectivitycheck.gstatic.com" {
179+
probeIdx = i
180+
break
181+
}
182+
}
183+
}
158184
if r.Outbound == "direct" && len(r.ProcessPathRegex) > 0 {
159-
t.Fatalf("unexpected process self direct rule for wireguard endpoint, rules=%+v", route.Rules)
185+
selfDirectIdx = i
160186
}
161187
}
162-
}
163-
164-
func TestBuildRoute_TunnelMode_AmneziaWGDoesNotIncludeSelfDirectRule(t *testing.T) {
165-
cfg := EngineConfig{
166-
Mode: ProxyModeTunnel,
167-
Proxy: ProxyConfig{Type: "amneziawg"},
188+
if probeIdx < 0 {
189+
t.Fatalf("%s: expected probe-domain → proxy rule, rules=%+v", proxyType, route.Rules)
168190
}
169-
route := buildRoute(cfg)
170-
if route == nil {
171-
t.Fatal("expected non-nil route")
191+
if selfDirectIdx < 0 {
192+
t.Fatalf("%s: expected self-direct rule, rules=%+v", proxyType, route.Rules)
172193
}
173-
for _, r := range route.Rules {
174-
if r.Outbound == "direct" && len(r.ProcessPathRegex) > 0 {
175-
t.Fatalf("unexpected process self direct rule for amnezia endpoint, rules=%+v", route.Rules)
176-
}
194+
if probeIdx > selfDirectIdx {
195+
t.Fatalf("%s: probe-domain rule (idx=%d) must precede self-direct rule (idx=%d)", proxyType, probeIdx, selfDirectIdx)
177196
}
178197
}
179198

0 commit comments

Comments
 (0)