diff --git a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt index 02886a6..115e729 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.DEBUG_PROPERTY_NAME import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON import libcore.Libcore import moe.matsuri.nb4a.NativeInterface +import moe.matsuri.nb4a.net.LocalResolverImpl import moe.matsuri.nb4a.utils.JavaUtil import moe.matsuri.nb4a.utils.cleanWebview import java.io.File @@ -78,7 +79,7 @@ class SagerNet : Application(), externalAssets.absolutePath + "/", DataStore.logBufSize, DataStore.logLevel > 0, - nativeInterface, nativeInterface + nativeInterface, nativeInterface, LocalResolverImpl ) if (isMainProcess) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt index 943674b..2821348 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -58,6 +58,7 @@ object RawUpdater : GroupUpdater() { val response = Libcore.newHttpClient().apply { trySocks5(DataStore.mixedPort) + tryH3Direct() when (DataStore.appTLSVersion) { "1.3" -> restrictedTLS() } @@ -73,6 +74,17 @@ object RawUpdater : GroupUpdater() { subscription.subscriptionUserinfo = Util.getStringBox(response.getHeader("Subscription-Userinfo")) + + // 修改默认名字 + if (proxyGroup.name?.startsWith("Subscription #") == true) { + var remoteName = Util.getStringBox(response.getHeader("content-disposition")) + if (remoteName.isNotBlank()) { + remoteName = Util.decodeFilename(remoteName) + if (remoteName.isNotBlank()) { + proxyGroup.name = remoteName + } + } + } } val proxiesMap = LinkedHashMap() diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt index 3609ae6..7ed131d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt @@ -216,7 +216,6 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { try { val client = Libcore.newHttpClient().apply { modernTLS() - keepAlive() trySocks5(DataStore.mixedPort) } val response = client.newRequest().apply { diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt b/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt index 35df4ee..811d705 100644 --- a/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt +++ b/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt @@ -5,6 +5,8 @@ import android.content.Context import android.util.Base64 import libcore.StringBox import java.io.ByteArrayOutputStream +import java.net.URLDecoder +import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.* import java.util.zip.Deflater @@ -188,4 +190,11 @@ object Util { } return "" } + + fun decodeFilename(headerValue: String): String { + val regex = Regex("filename\\*=[^']*''(.+)") + val match = regex.find(headerValue) + val encoded = match?.groupValues?.get(1) ?: "" + return URLDecoder.decode(encoded, StandardCharsets.UTF_8.name()) + } } diff --git a/libcore/dns_box.go b/libcore/dns_box.go index 463aa39..8d2ff48 100644 --- a/libcore/dns_box.go +++ b/libcore/dns_box.go @@ -25,6 +25,8 @@ type LocalDNSTransport interface { Exchange(ctx *ExchangeContext, message []byte) error } +var gLocalDNSTransport *platformLocalDNSTransport = nil + type platformLocalDNSTransport struct { iif LocalDNSTransport tag string diff --git a/libcore/ech/ech.go b/libcore/ech/ech.go new file mode 100644 index 0000000..1dbf50a --- /dev/null +++ b/libcore/ech/ech.go @@ -0,0 +1,83 @@ +package ech + +import ( + "context" + "crypto/tls" + "encoding/base64" + "net" + "os" + + mDNS "github.com/miekg/dns" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing/common/exceptions" +) + +type ECHClientConfig struct { + *tls.Config + domain string + localDnsTransport adapter.DNSTransport +} + +func NewECHClientConfig(domain string, tlsConfig *tls.Config, localDnsTransport adapter.DNSTransport) *ECHClientConfig { + config := tlsConfig.Clone() + config.ServerName = domain + return &ECHClientConfig{ + Config: config, + domain: domain, + localDnsTransport: localDnsTransport, + } +} + +// ClientHandshake 封装 TLS 握手 +func (s *ECHClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) (*tls.Conn, error) { + tlsConn, err := s.fetchAndHandshake(ctx, conn) + if err != nil { + return nil, err + } + err = tlsConn.HandshakeContext(ctx) + if err != nil { + return nil, err + } + return tlsConn, nil +} + +// fetchAndHandshake 查询 ECHConfigList 并完成 TLS 连接 +func (s *ECHClientConfig) fetchAndHandshake(ctx context.Context, conn net.Conn) (*tls.Conn, error) { + message := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{ + { + Name: mDNS.Fqdn(s.domain), + Qtype: mDNS.TypeHTTPS, + Qclass: mDNS.ClassINET, + }, + }, + } + if s.localDnsTransport == nil { + return nil, os.ErrInvalid + } + response, err := s.localDnsTransport.Exchange(ctx, message) + if err != nil { + return nil, exceptions.Cause(err, "fetch ECH config list") + } + if response.Rcode != mDNS.RcodeSuccess { + return nil, exceptions.Cause(dns.RcodeError(response.Rcode), "fetch ECH config list") + } + for _, rr := range response.Answer { + switch resource := rr.(type) { + case *mDNS.HTTPS: + for _, value := range resource.Value { + if value.Key().String() == "ech" { + echConfigList, err := base64.StdEncoding.DecodeString(value.String()) + if err == nil { + s.Config.EncryptedClientHelloConfigList = echConfigList + } + } + } + } + } + return tls.Client(conn, s.Config), nil +} diff --git a/libcore/go.mod b/libcore/go.mod index d401115..42ea05b 100644 --- a/libcore/go.mod +++ b/libcore/go.mod @@ -8,6 +8,7 @@ require ( github.com/matsuridayo/libneko v1.0.0 // replaced github.com/miekg/dns v1.1.67 github.com/oschwald/maxminddb-golang v1.13.1 + github.com/sagernet/quic-go v0.52.0-beta.1 github.com/sagernet/sing v0.7.6-0.20250825114712-2aeec120ce28 github.com/sagernet/sing-box v1.0.0 // replaced github.com/sagernet/sing-tun v0.7.0-beta.1 @@ -51,7 +52,6 @@ require ( github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect - github.com/sagernet/quic-go v0.52.0-beta.1 // indirect github.com/sagernet/sing-mux v0.3.3 // indirect github.com/sagernet/sing-quic v0.5.0 // indirect github.com/sagernet/sing-shadowsocks v0.2.8 // indirect diff --git a/libcore/http.go b/libcore/http.go index db15a9a..081685d 100644 --- a/libcore/http.go +++ b/libcore/http.go @@ -10,24 +10,34 @@ import ( "errors" "fmt" "io" + "libcore/device" + "libcore/ech" + "log" "net" "net/http" "net/url" "os" "strconv" "sync" + "sync/atomic" + "time" + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/protocol/socks" "github.com/sagernet/sing/protocol/socks/socks5" ) +var errFailConnectSocks5 = errors.New("fail connect socks5") + type HTTPClient interface { RestrictedTLS() ModernTLS() PinnedTLS12() PinnedSHA256(sumHex string) TrySocks5(port int32) + TryH3Direct() KeepAlive() NewRequest() HTTPRequest Close() @@ -58,16 +68,18 @@ var ( ) type httpClient struct { - tls tls.Config - client http.Client - transport http.Transport + tls tls.Config + h1h2Transport http.Transport + h1h2Client http.Client + trySocks5 bool + tryH3Direct bool } func NewHttpClient() HTTPClient { client := new(httpClient) - client.client.Transport = &client.transport - client.transport.TLSClientConfig = &client.tls - client.transport.DisableKeepAlives = true + client.h1h2Client.Transport = &client.h1h2Transport + client.h1h2Transport.TLSClientConfig = &client.tls + client.h1h2Transport.DisableKeepAlives = true return client } @@ -104,25 +116,36 @@ func (c *httpClient) PinnedSHA256(sumHex string) { func (c *httpClient) TrySocks5(port int32) { dialer := new(net.Dialer) - c.transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + c.h1h2Transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { for { socksConn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(int(port))) if err != nil { + if c.tryH3Direct { + return nil, errFailConnectSocks5 + } break } _, err = socks.ClientHandshake5(socksConn, socks5.CommandConnect, metadata.ParseSocksaddr(addr), "", "") if err != nil { + if c.tryH3Direct { + return nil, errFailConnectSocks5 + } break } return socksConn, err } return dialer.DialContext(ctx, network, addr) } + c.trySocks5 = true +} + +func (c *httpClient) TryH3Direct() { + c.tryH3Direct = true } func (c *httpClient) KeepAlive() { - c.transport.ForceAttemptHTTP2 = true - c.transport.DisableKeepAlives = false + c.h1h2Transport.ForceAttemptHTTP2 = true + c.h1h2Transport.DisableKeepAlives = false } func (c *httpClient) NewRequest() HTTPRequest { @@ -135,7 +158,7 @@ func (c *httpClient) NewRequest() HTTPRequest { } func (c *httpClient) Close() { - c.transport.CloseIdleConnections() + c.h1h2Transport.CloseIdleConnections() } type httpRequest struct { @@ -184,8 +207,16 @@ func (r *httpRequest) SetContentString(content string) { } func (r *httpRequest) Execute() (HTTPResponse, error) { - response, err := r.client.Do(&r.request) + // full direct + if r.tryH3Direct && !r.trySocks5 { + return r.doH3Direct() + } + response, err := r.h1h2Client.Do(&r.request) if err != nil { + // trySocks5 && tryH3Direct + if r.tryH3Direct && errors.Is(err, errFailConnectSocks5) { + return r.doH3Direct() + } return nil, err } httpResp := &httpResponse{Response: response} @@ -195,6 +226,129 @@ func (r *httpRequest) Execute() (HTTPResponse, error) { return httpResp, nil } +type requestFunc func() (response *http.Response, err error) + +func (r *httpRequest) doH3Direct() (HTTPResponse, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + successCh := make(chan *http.Response, 1) + var finalErr error + var failedCount atomic.Uint32 + var successCount atomic.Uint32 + var mu sync.Mutex + + funcs := []requestFunc{ + // 普通,不再重试 socks5 + func() (response *http.Response, err error) { + request := r.request.Clone(context.Background()) + h1h2Client := &http.Client{ + Transport: &http.Transport{ + DisableKeepAlives: true, + }, + } + return h1h2Client.Do(request) + }, + // ECH HTTPS + func() (response *http.Response, err error) { + request := r.request.Clone(context.Background()) + echClient := &http.Client{ + Transport: &http.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + c, err := d.DialContext(ctx, network, addr) + if err != nil { + return c, err + } + domain := addr + if host, _, _ := net.SplitHostPort(addr); host != "" { + domain = host + } + echTls := ech.NewECHClientConfig(domain, &r.tls, gLocalDNSTransport) + return echTls.ClientHandshake(ctx, c) + }, + DisableKeepAlives: true, + }, + } + return echClient.Do(request) + }, + // H3 + func() (response *http.Response, err error) { + request := r.request.Clone(context.Background()) + h3Client := &http.Client{ + Transport: &http3.Transport{ + TLSClientConfig: r.tls.Clone(), + QUICConfig: &quic.Config{ + MaxIdleTimeout: time.Second, + }, + }, + } + return h3Client.Do(request) + }, + } + + for i, f := range funcs { + go func(f requestFunc) { + defer device.DeferPanicToError("http", func(err error) { + log.Println(err) + }) + defer func() { + if successCount.Load() == 0 { + if failedCount.Add(1) >= uint32(len(funcs)) { + // 全部失败了 + cancel() + } + } + }() + + var t string + switch i { + case 0: + t = "h1h2" + case 1: + t = "ech" + case 2: + t = "h3" + } + + // 执行HTTP请求 + rsp, err := f() + if rsp == nil || err != nil { + mu.Lock() + finalErr = errors.Join(finalErr, fmt.Errorf("%s: %w", t, err)) + mu.Unlock() + return + } + + // 处理 HTTP 状态码 + if rsp.StatusCode != http.StatusOK { + hr := &httpResponse{Response: rsp} + err = errors.Join(finalErr, fmt.Errorf("%s: %s", t, hr.errorString())) + mu.Lock() + finalErr = err + mu.Unlock() + rsp.Body.Close() + return + } + + select { + case successCh <- rsp: + // 第一个成功的请求,不要关闭 body + successCount.Add(1) + default: + rsp.Body.Close() + } + }(f) + } + + select { + case result := <-successCh: + return &httpResponse{Response: result}, nil + case <-ctx.Done(): + return nil, finalErr + } +} + type httpResponse struct { *http.Response diff --git a/libcore/nb4a.go b/libcore/nb4a.go index 775bb5c..903386e 100644 --- a/libcore/nb4a.go +++ b/libcore/nb4a.go @@ -34,7 +34,7 @@ func ForceGc() { func InitCore(process, cachePath, internalAssets, externalAssets string, maxLogSizeKb int32, logEnable bool, - if1 NB4AInterface, if2 BoxPlatformInterface, + if1 NB4AInterface, if2 BoxPlatformInterface, if3 LocalDNSTransport, ) { defer device.DeferPanicToError("InitCore", func(err error) { log.Println(err) }) isBgProcess = strings.HasSuffix(process, ":bg") @@ -43,6 +43,7 @@ func InitCore(process, cachePath, internalAssets, externalAssets string, intfNB4A = if1 intfBox = if2 useProcfs = intfBox.UseProcFS() + gLocalDNSTransport = &platformLocalDNSTransport{iif: if3} // Working dir tmp := filepath.Join(cachePath, "../no_backup")