@@ -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+
437480func 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
539607func 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 ) + `$`
0 commit comments