diff --git a/.github/update.log b/.github/update.log index 7ed6b0e497..6ffe65cc83 100644 --- a/.github/update.log +++ b/.github/update.log @@ -1332,3 +1332,4 @@ Update On Mon Apr 13 21:25:44 CEST 2026 Update On Tue Apr 14 21:27:36 CEST 2026 Update On Wed Apr 15 21:30:45 CEST 2026 Update On Thu Apr 16 21:28:40 CEST 2026 +Update On Fri Apr 17 21:15:01 CEST 2026 diff --git a/clash-meta/adapter/outbound/singmux.go b/clash-meta/adapter/outbound/singmux.go index 44b03b64f1..0d9233205c 100644 --- a/clash-meta/adapter/outbound/singmux.go +++ b/clash-meta/adapter/outbound/singmux.go @@ -2,7 +2,6 @@ package outbound import ( "context" - "time" N "github.com/metacubex/mihomo/common/net" "github.com/metacubex/mihomo/component/proxydialer" @@ -29,7 +28,6 @@ type SingMuxOption struct { Padding bool `proxy:"padding,omitempty"` Statistic bool `proxy:"statistic,omitempty"` OnlyTcp bool `proxy:"only-tcp,omitempty"` - TCPTimeout int `proxy:"tcp-timeout,omitempty"` BrutalOpts BrutalOption `proxy:"brutal-opts,omitempty"` } @@ -105,7 +103,7 @@ func NewSingMux(option SingMuxOption, proxy ProxyAdapter) (ProxyAdapter, error) MinStreams: option.MinStreams, MaxStreams: option.MaxStreams, Padding: option.Padding, - TCPTimeout: time.Duration(option.TCPTimeout) * time.Second, + TCPTimeout: C.DefaultTCPTimeout, Brutal: mux.BrutalOptions{ Enabled: option.BrutalOpts.Enabled, SendBPS: StringToBps(option.BrutalOpts.Up), diff --git a/clash-meta/adapter/outbound/socks5.go b/clash-meta/adapter/outbound/socks5.go index 514223b95c..912856fdf6 100644 --- a/clash-meta/adapter/outbound/socks5.go +++ b/clash-meta/adapter/outbound/socks5.go @@ -98,8 +98,6 @@ func (ss *Socks5) ListenPacketContext(ctx context.Context, metadata *C.Metadata) if ss.tls { cc := tls.Client(c, ss.tlsConfig) - ctx, cancel := context.WithTimeout(context.Background(), C.DefaultTLSTimeout) - defer cancel() err = cc.HandshakeContext(ctx) c = cc } diff --git a/clash-meta/go.mod b/clash-meta/go.mod index 82039318ec..9f1f593ffb 100644 --- a/clash-meta/go.mod +++ b/clash-meta/go.mod @@ -29,7 +29,7 @@ require ( github.com/metacubex/randv2 v0.2.0 github.com/metacubex/restls-client-go v0.1.7 github.com/metacubex/sing v0.5.7 - github.com/metacubex/sing-mux v0.3.7 + github.com/metacubex/sing-mux v0.3.9 github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a github.com/metacubex/sing-shadowsocks v0.2.12 github.com/metacubex/sing-shadowsocks2 v0.2.7 diff --git a/clash-meta/go.sum b/clash-meta/go.sum index 47786440ce..aa667bd763 100644 --- a/clash-meta/go.sum +++ b/clash-meta/go.sum @@ -125,8 +125,8 @@ github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58d github.com/metacubex/restls-client-go v0.1.7/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM= github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w= -github.com/metacubex/sing-mux v0.3.7 h1:CnsRQfxFOLNh8gZqbPVFVPNVWgq5paiLksI0tVJI5CY= -github.com/metacubex/sing-mux v0.3.7/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= +github.com/metacubex/sing-mux v0.3.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk= +github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a h1:977o0ZYYbiQAGuOxql7Q6UN3rEy59OyAE0tELq4gZfI= github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE= diff --git a/clash-meta/listener/inbound/mux_test.go b/clash-meta/listener/inbound/mux_test.go index 841797e66c..96841dbf1b 100644 --- a/clash-meta/listener/inbound/mux_test.go +++ b/clash-meta/listener/inbound/mux_test.go @@ -29,9 +29,8 @@ func testSingMux(t *testing.T, tunnel *TestTunnel, out outbound.ProxyAdapter) { protocol := protocol t.Run(protocol, func(t *testing.T) { singMuxOption := outbound.SingMuxOption{ - Enabled: true, - Protocol: protocol, - TCPTimeout: 30, // set a timeout value that is slightly larger than the default + Enabled: true, + Protocol: protocol, } out, err := outbound.NewSingMux(singMuxOption, ¬CloseProxyAdapter{out}) if !assert.NoError(t, err) { diff --git a/clash-meta/transport/trusttunnel/client.go b/clash-meta/transport/trusttunnel/client.go index 32f56f9372..f534e7d727 100644 --- a/clash-meta/transport/trusttunnel/client.go +++ b/clash-meta/transport/trusttunnel/client.go @@ -155,16 +155,16 @@ func (c *Client) roundTrip(request *http.Request, conn *httpConn) { if err != nil { _ = pipeWriter.CloseWithError(err) _ = pipeReader.CloseWithError(err) - conn.setUp(nil, err) + conn.setup(nil, err) } else if response.StatusCode != http.StatusOK { _ = response.Body.Close() err = fmt.Errorf("unexpected status code: %d", response.StatusCode) _ = pipeWriter.CloseWithError(err) _ = pipeReader.CloseWithError(err) - conn.setUp(nil, err) + conn.setup(nil, err) } else { c.resetHealthCheckTimer() - conn.setUp(response.Body, nil) + conn.setup(response.Body, nil) } }() } diff --git a/clash-meta/transport/trusttunnel/protocol.go b/clash-meta/transport/trusttunnel/protocol.go index d25f5fe7d2..93374b1605 100644 --- a/clash-meta/transport/trusttunnel/protocol.go +++ b/clash-meta/transport/trusttunnel/protocol.go @@ -10,6 +10,7 @@ import ( "net/netip" "runtime" "strings" + "sync" "time" "github.com/metacubex/mihomo/common/httputils" @@ -95,6 +96,7 @@ type httpConn struct { writer io.Writer flusher http.Flusher body io.ReadCloser + setupOnce sync.Once created chan struct{} createErr error cancelFn func() @@ -105,19 +107,28 @@ type httpConn struct { deadline *time.Timer } -func (h *httpConn) setUp(body io.ReadCloser, err error) { - h.body = body - h.createErr = err - close(h.created) +func (h *httpConn) setup(body io.ReadCloser, err error) { + h.setupOnce.Do(func() { + h.body = body + h.createErr = err + close(h.created) + }) + if h.createErr != nil && body != nil { // conn already closed before setup + _ = body.Close() + } } func (h *httpConn) waitCreated() error { <-h.created + if h.body != nil { + return nil + } return h.createErr } func (h *httpConn) Close() error { var errorArr []error + h.setup(nil, net.ErrClosed) if closer, ok := h.writer.(io.Closer); ok { errorArr = append(errorArr, closer.Close()) } diff --git a/clash-meta/transport/trusttunnel/service.go b/clash-meta/transport/trusttunnel/service.go index 9e827ffa2f..1e73b2036d 100644 --- a/clash-meta/transport/trusttunnel/service.go +++ b/clash-meta/transport/trusttunnel/service.go @@ -167,7 +167,7 @@ func (s *Service) ServeHTTP(writer http.ResponseWriter, request *http.Request) { }, } httputils.SetAddrFromRequest(&conn.NetAddr, request) - conn.setUp(request.Body, nil) + conn.setup(request.Body, nil) firstPacket := buf.NewPacket() destination, err := conn.ReadPacket(firstPacket) if err != nil { @@ -204,7 +204,7 @@ func (s *Service) ServeHTTP(writer http.ResponseWriter, request *http.Request) { }, } httputils.SetAddrFromRequest(&conn.NetAddr, request) - conn.setUp(request.Body, nil) + conn.setup(request.Body, nil) s.icmpHandler.NewICMPConnection(ctx, conn) } case HealthCheckMagicAddress: @@ -227,7 +227,7 @@ func (s *Service) ServeHTTP(writer http.ResponseWriter, request *http.Request) { }, } httputils.SetAddrFromRequest(&conn.NetAddr, request) - conn.setUp(request.Body, nil) + conn.setup(request.Body, nil) _ = s.handler.NewConnection(ctx, N.NewDeadlineConn(conn), M.Metadata{ Protocol: "trusttunnel", Source: M.ParseSocksaddr(request.RemoteAddr), diff --git a/clash-meta/transport/xhttp/client.go b/clash-meta/transport/xhttp/client.go index b7a33221fe..3e7c726df4 100644 --- a/clash-meta/transport/xhttp/client.go +++ b/clash-meta/transport/xhttp/client.go @@ -17,6 +17,7 @@ import ( "github.com/metacubex/mihomo/common/httputils" "github.com/metacubex/http" + "github.com/metacubex/http/httptrace" "github.com/metacubex/quic-go" "github.com/metacubex/quic-go/http3" "github.com/metacubex/tls" @@ -344,7 +345,21 @@ func (c *Client) DialStreamOne() (net.Conn, error) { conn := &Conn{writer: pw} - req, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, c.ctx), http.MethodPost, requestURL.String(), pr) + // Use gotConn to detect when TCP connection is established, so we can + // return the conn immediately without waiting for the HTTP response. + // This breaks the deadlock where CDN buffers response headers until the + // server sends body data, but the server waits for our request body, + // which can't be sent because we haven't returned the conn yet. + gotConn := make(chan bool, 1) + + addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx) + ctx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + gotConn <- true + }, + }) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), pr) if err != nil { _ = pr.Close() _ = pw.Close() @@ -360,21 +375,34 @@ func (c *Client) DialStreamOne() (net.Conn, error) { return nil, err } - resp, err := transport.RoundTrip(req) - if err != nil { + wrc := NewWaitReadCloser() + + go func() { + resp, err := transport.RoundTrip(req) + if err != nil { + wrc.CloseWithError(err) + close(gotConn) + return + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _ = resp.Body.Close() + wrc.CloseWithError(fmt.Errorf("xhttp stream-one bad status: %s", resp.Status)) + return + } + wrc.Set(resp.Body) + }() + + if !<-gotConn { + // RoundTrip failed before TCP connected (e.g. DNS failure) _ = pr.Close() _ = pw.Close() httputils.CloseTransport(transport) + var buf [0]byte + _, err = wrc.Read(buf[:]) return nil, err } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - _ = resp.Body.Close() - _ = pr.Close() - _ = pw.Close() - httputils.CloseTransport(transport) - return nil, fmt.Errorf("xhttp stream-one bad status: %s", resp.Status) - } - conn.reader = resp.Body + + conn.reader = wrc conn.onClose = func() { _ = pr.Close() httputils.CloseTransport(transport) @@ -411,8 +439,18 @@ func (c *Client) DialStreamUp() (net.Conn, error) { sessionID := newSessionID() + // Async download: avoid blocking on CDN response header buffering + gotConn := make(chan bool, 1) + + addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx) + downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + gotConn <- true + }, + }) + downloadReq, err := http.NewRequestWithContext( - httputils.NewAddrContext(&conn.NetAddr, c.ctx), + downloadCtx, http.MethodGet, downloadURL.String(), nil, @@ -449,19 +487,36 @@ func (c *Client) DialStreamUp() (net.Conn, error) { } uploadReq.Host = c.cfg.Host - downloadResp, err := downloadTransport.RoundTrip(downloadReq) - if err != nil { + wrc := NewWaitReadCloser() + + go func() { + resp, err := downloadTransport.RoundTrip(downloadReq) + if err != nil { + wrc.CloseWithError(err) + close(gotConn) + return + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + wrc.CloseWithError(fmt.Errorf("xhttp stream-up download bad status: %s", resp.Status)) + return + } + wrc.Set(resp.Body) + }() + + if !<-gotConn { + _ = pr.Close() + _ = pw.Close() httputils.CloseTransport(uploadTransport) httputils.CloseTransport(downloadTransport) + var buf [0]byte + _, err = wrc.Read(buf[:]) return nil, err } - if downloadResp.StatusCode != http.StatusOK { - _ = downloadResp.Body.Close() - httputils.CloseTransport(uploadTransport) - httputils.CloseTransport(downloadTransport) - return nil, fmt.Errorf("xhttp stream-up download bad status: %s", downloadResp.Status) - } + // Start upload after download TCP is connected, so the server has likely + // already processed the GET and created the session. This preserves the + // original ordering (download before upload) while still being async. go func() { resp, err := uploadTransport.RoundTrip(uploadReq) if err != nil { @@ -476,7 +531,7 @@ func (c *Client) DialStreamUp() (net.Conn, error) { } }() - conn.reader = downloadResp.Body + conn.reader = wrc conn.onClose = func() { _ = pr.Close() httputils.CloseTransport(uploadTransport) @@ -518,8 +573,18 @@ func (c *Client) DialPacketUp() (net.Conn, error) { writer.writeCond = sync.Cond{L: &writer.writeMu} conn := &Conn{writer: writer} + // Async download: avoid blocking on CDN response header buffering + gotConn := make(chan bool, 1) + + addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx) + downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + gotConn <- true + }, + }) + downloadReq, err := http.NewRequestWithContext( - httputils.NewAddrContext(&conn.NetAddr, c.ctx), + downloadCtx, http.MethodGet, downloadURL.String(), nil, @@ -536,20 +601,32 @@ func (c *Client) DialPacketUp() (net.Conn, error) { } downloadReq.Host = downloadCfg.Host - resp, err := downloadTransport.RoundTrip(downloadReq) - if err != nil { + wrc := NewWaitReadCloser() + + go func() { + resp, err := downloadTransport.RoundTrip(downloadReq) + if err != nil { + wrc.CloseWithError(err) + close(gotConn) + return + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + wrc.CloseWithError(fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)) + return + } + wrc.Set(resp.Body) + }() + + if !<-gotConn { httputils.CloseTransport(uploadTransport) httputils.CloseTransport(downloadTransport) + var buf [0]byte + _, err = wrc.Read(buf[:]) return nil, err } - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - httputils.CloseTransport(uploadTransport) - httputils.CloseTransport(downloadTransport) - return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status) - } - conn.reader = resp.Body + conn.reader = wrc conn.onClose = func() { // uploadTransport already closed by writer httputils.CloseTransport(downloadTransport) @@ -563,3 +640,59 @@ func newSessionID() string { _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) } + +// WaitReadCloser is an io.ReadCloser that blocks on Read() until the underlying +// ReadCloser is provided via Set(). This enables returning a reader immediately +// while the actual HTTP response body is obtained asynchronously in a goroutine, +// breaking the synchronous RoundTrip deadlock with CDN header buffering. +type WaitReadCloser struct { + wait chan struct{} + once sync.Once + rc io.ReadCloser + err error +} + +func NewWaitReadCloser() *WaitReadCloser { + return &WaitReadCloser{wait: make(chan struct{})} +} + +// Set provides the underlying ReadCloser and unblocks any pending Read calls. +// Must be called at most once. If Close was already called, rc is closed to +// prevent leaks. +func (w *WaitReadCloser) Set(rc io.ReadCloser) { + w.setup(rc, nil) +} + +// CloseWithError records an error and unblocks any pending Read calls. +func (w *WaitReadCloser) CloseWithError(err error) { + w.setup(nil, err) +} + +// setup sets the underlying ReadCloser and error. +func (w *WaitReadCloser) setup(rc io.ReadCloser, err error) { + w.once.Do(func() { + w.rc = rc + w.err = err + close(w.wait) + }) + if w.err != nil && rc != nil { + _ = rc.Close() + } +} + +func (w *WaitReadCloser) Read(b []byte) (int, error) { + <-w.wait + if w.rc == nil { + return 0, w.err + } + return w.rc.Read(b) +} + +func (w *WaitReadCloser) Close() error { + w.setup(nil, net.ErrClosed) + <-w.wait + if w.rc != nil { + return w.rc.Close() + } + return nil +} diff --git a/clash-meta/transport/xhttp/server.go b/clash-meta/transport/xhttp/server.go index ce71bb1c17..c8b4fa84c0 100644 --- a/clash-meta/transport/xhttp/server.go +++ b/clash-meta/transport/xhttp/server.go @@ -145,9 +145,24 @@ func (h *requestHandler) getOrCreateSession(sessionID string) *httpSession { s = newHTTPSession(h.scMaxBufferedPosts.Max) h.sessions[sessionID] = s + + // Reap orphan sessions that never become fully connected (e.g. from probing). + // Matches Xray-core's 30-second reaper in upsertSession. + go func() { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + select { + case <-timer.C: + h.deleteSession(sessionID) + case <-s.connected: + } + }() + return s } + + func (h *requestHandler) deleteSession(sessionID string) { h.mu.Lock() defer h.mu.Unlock() @@ -299,11 +314,7 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // stream-up upload: POST /path/{session} if r.Method == http.MethodPost && len(parts) == 1 && h.allowStreamUpUpload() { sessionID := parts[0] - session := h.getSession(sessionID) - if session == nil { - http.Error(w, "unknown xhttp session", http.StatusBadRequest) - return - } + session := h.getOrCreateSession(sessionID) httpSC := newHTTPServerConn(w, r.Body) err := session.uploadQueue.Push(Packet{ @@ -360,11 +371,7 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - session := h.getSession(sessionID) - if session == nil { - http.Error(w, "unknown xhttp session", http.StatusBadRequest) - return - } + session := h.getOrCreateSession(sessionID) if r.ContentLength > int64(h.scMaxEachPostBytes.Max) { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) diff --git a/clash-meta/transport/xhttp/upload_queue.go b/clash-meta/transport/xhttp/upload_queue.go index 3172a90bfc..b7bff4329e 100644 --- a/clash-meta/transport/xhttp/upload_queue.go +++ b/clash-meta/transport/xhttp/upload_queue.go @@ -6,6 +6,8 @@ import ( "sync" ) +var ErrQueueTooLarge = errors.New("packet queue is too large") + type Packet struct { Seq uint64 Payload []byte // UploadQueue will hold Payload, so never reuse it after UploadQueue.Push @@ -97,7 +99,7 @@ func (q *UploadQueue) Read(b []byte) (int, error) { q.mu.Unlock() // the "reassembly buffer" is too large, and we want to constrain memory usage somehow. // let's tear down the connection and hope the application retries. - return 0, errors.New("packet queue is too large") + return 0, ErrQueueTooLarge } q.condPushed.Wait() diff --git a/clash-meta/transport/xhttp/upload_queue_test.go b/clash-meta/transport/xhttp/upload_queue_test.go new file mode 100644 index 0000000000..2f4882c7bc --- /dev/null +++ b/clash-meta/transport/xhttp/upload_queue_test.go @@ -0,0 +1,53 @@ +package xhttp + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUploadQueueMaxPackets(t *testing.T) { + q := NewUploadQueue(2) + ch := make(chan struct{}) + go func() { + err := q.Push(Packet{Seq: 0, Payload: []byte{'0'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 1, Payload: []byte{'1'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 2, Payload: []byte{'2'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 4, Payload: []byte{'4'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 5, Payload: []byte{'5'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 6, Payload: []byte{'6'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 7, Payload: []byte{'7'}}) + assert.ErrorIs(t, err, io.ErrClosedPipe) + close(ch) + }() + + buf := make([]byte, 20) + n, err := q.Read(buf) + assert.Equal(t, 1, n) + assert.Equal(t, []byte{'0'}, buf[:n]) + assert.NoError(t, err) + + n, err = q.Read(buf) + assert.Equal(t, 1, n) + assert.Equal(t, []byte{'1'}, buf[:n]) + + n, err = q.Read(buf) + assert.Equal(t, 1, n) + assert.Equal(t, []byte{'2'}, buf[:n]) + + n, err = q.Read(buf) + assert.Equal(t, 0, n) + assert.ErrorIs(t, err, ErrQueueTooLarge) + + err = q.Close() + assert.NoError(t, err) + + <-ch +} diff --git a/clash-nyanpasu/backend/Cargo.lock b/clash-nyanpasu/backend/Cargo.lock index 69aab65432..c09038f9bc 100644 --- a/clash-nyanpasu/backend/Cargo.lock +++ b/clash-nyanpasu/backend/Cargo.lock @@ -379,7 +379,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -772,9 +772,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "base64 0.22.1", @@ -799,7 +799,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1514,9 +1514,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -1536,9 +1536,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1629,7 +1629,7 @@ dependencies = [ "port_scanner", "postcard", "pretty_assertions", - "rand 0.10.0", + "rand 0.10.1", "rayon", "redb", "reqwest 0.12.28", @@ -1671,7 +1671,7 @@ dependencies = [ "time", "timeago", "tokio", - "tokio-tungstenite 0.29.0", + "tokio-tungstenite", "tokio-util", "tracing", "tracing-appender", @@ -1743,7 +1743,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2354,7 +2354,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2883,7 +2883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4913,7 +4913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.3", + "windows-targets 0.48.5", ] [[package]] @@ -6313,7 +6313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -7295,7 +7295,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7350,9 +7350,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.1", @@ -7951,7 +7951,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8009,7 +8009,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9001,6 +9001,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -9696,10 +9702,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.3.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10012,18 +10018,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -10033,7 +10027,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -10215,12 +10209,13 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "parking_lot", + "symlink", "thiserror 2.0.18", "time", "tracing-subscriber", @@ -10388,23 +10383,6 @@ dependencies = [ "core_maths", ] -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.29.0" @@ -11491,7 +11469,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/clash-nyanpasu/frontend/nyanpasu/package.json b/clash-nyanpasu/frontend/nyanpasu/package.json index 786cfd1903..d78eb6f9d9 100644 --- a/clash-nyanpasu/frontend/nyanpasu/package.json +++ b/clash-nyanpasu/frontend/nyanpasu/package.json @@ -99,7 +99,7 @@ "filesize": "11.0.15", "meta-json-schema": "1.19.23", "monaco-yaml": "5.4.1", - "nanoid": "5.1.7", + "nanoid": "5.1.9", "sass-embedded": "1.99.0", "shiki": "4.0.2", "unplugin-auto-import": "21.0.0", diff --git a/clash-nyanpasu/manifest/version.json b/clash-nyanpasu/manifest/version.json index f63ba9a9bf..dec15c3c06 100644 --- a/clash-nyanpasu/manifest/version.json +++ b/clash-nyanpasu/manifest/version.json @@ -2,7 +2,7 @@ "manifest_version": 1, "latest": { "mihomo": "v1.19.23", - "mihomo_alpha": "alpha-83ac220", + "mihomo_alpha": "alpha-7ab4eed", "clash_rs": "v0.9.7", "clash_premium": "2023-09-05-gdcc8d87", "clash_rs_alpha": "0.9.7-alpha+sha.dd693bf" @@ -69,5 +69,5 @@ "linux-armv7hf": "clash-rs-armv7-unknown-linux-gnueabihf" } }, - "updated_at": "2026-04-15T22:28:44.019Z" + "updated_at": "2026-04-16T22:25:52.280Z" } diff --git a/clash-nyanpasu/package.json b/clash-nyanpasu/package.json index 5b7c69b2fd..42dbee900f 100644 --- a/clash-nyanpasu/package.json +++ b/clash-nyanpasu/package.json @@ -72,7 +72,7 @@ "lint-staged": "16.4.0", "npm-run-all2": "8.0.4", "oxlint": "1.59.0", - "postcss": "8.5.9", + "postcss": "8.5.10", "postcss-html": "1.8.1", "postcss-import": "16.1.1", "postcss-scss": "4.0.9", diff --git a/clash-nyanpasu/pnpm-lock.yaml b/clash-nyanpasu/pnpm-lock.yaml index b77704a118..3c55c786dd 100644 --- a/clash-nyanpasu/pnpm-lock.yaml +++ b/clash-nyanpasu/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: version: 24.11.0 autoprefixer: specifier: 10.4.27 - version: 10.4.27(postcss@8.5.9) + version: 10.4.27(postcss@8.5.10) conventional-changelog-conventionalcommits: specifier: 9.3.1 version: 9.3.1 @@ -71,17 +71,17 @@ importers: specifier: 1.59.0 version: 1.59.0 postcss: - specifier: 8.5.9 - version: 8.5.9 + specifier: 8.5.10 + version: 8.5.10 postcss-html: specifier: 1.8.1 version: 1.8.1 postcss-import: specifier: 16.1.1 - version: 16.1.1(postcss@8.5.9) + version: 16.1.1(postcss@8.5.10) postcss-scss: specifier: 4.0.9 - version: 4.0.9(postcss@8.5.9) + version: 4.0.9(postcss@8.5.10) prettier: specifier: 3.8.2 version: 3.8.2 @@ -424,8 +424,8 @@ importers: specifier: 5.4.1 version: 5.4.1(monaco-editor@0.55.1) nanoid: - specifier: 5.1.7 - version: 5.1.7 + specifier: 5.1.9 + version: 5.1.9 sass-embedded: specifier: 1.99.0 version: 1.99.0 @@ -449,7 +449,7 @@ importers: version: 3.2.2(vite@7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) vite-plugin-sass-dts: specifier: 1.3.37 - version: 1.3.37(postcss@8.5.9)(prettier@3.8.2)(sass-embedded@1.99.0)(vite@7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.3.37(postcss@8.5.10)(prettier@3.8.2)(sass-embedded@1.99.0)(vite@7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) vite-plugin-svgr: specifier: 4.5.0 version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -6506,8 +6506,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.7: - resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} + nanoid@5.1.9: + resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==} engines: {node: ^18 || >=20} hasBin: true @@ -6791,8 +6791,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.9: - resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} prettier-plugin-ember-template-tag@2.1.4: @@ -11566,7 +11566,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 - postcss: 8.5.9 + postcss: 8.5.10 tailwindcss: 4.2.2 '@tanstack/history@1.161.6': {} @@ -12011,11 +12011,11 @@ snapshots: '@types/postcss-modules-local-by-default@4.0.2': dependencies: - postcss: 8.5.9 + postcss: 8.5.10 '@types/postcss-modules-scope@3.0.4': dependencies: - postcss: 8.5.9 + postcss: 8.5.10 '@types/prop-types@15.7.15': {} @@ -12451,13 +12451,13 @@ snapshots: async@3.2.6: {} - autoprefixer@10.4.27(postcss@8.5.9): + autoprefixer@10.4.27(postcss@8.5.10): dependencies: browserslist: 4.28.1 caniuse-lite: 1.0.30001776 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.9 + postcss: 8.5.10 postcss-value-parser: 4.2.0 babel-dead-code-elimination@1.0.12: @@ -13522,9 +13522,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.9): + icss-utils@5.1.0(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 ieee754@1.2.1: {} @@ -14265,7 +14265,7 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.7: {} + nanoid@5.1.9: {} needle@3.3.1: dependencies: @@ -14549,59 +14549,59 @@ snapshots: dependencies: htmlparser2: 8.0.2 js-tokens: 9.0.1 - postcss: 8.5.9 - postcss-safe-parser: 6.0.0(postcss@8.5.9) + postcss: 8.5.10 + postcss-safe-parser: 6.0.0(postcss@8.5.10) - postcss-import@16.1.1(postcss@8.5.9): + postcss-import@16.1.1(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 - postcss-js@4.0.1(postcss@8.5.9): + postcss-js@4.0.1(postcss@8.5.10): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.9 + postcss: 8.5.10 - postcss-load-config@3.1.4(postcss@8.5.9): + postcss-load-config@3.1.4(postcss@8.5.10): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: - postcss: 8.5.9 + postcss: 8.5.10 postcss-media-query-parser@0.2.3: {} - postcss-modules-extract-imports@3.1.0(postcss@8.5.9): + postcss-modules-extract-imports@3.1.0(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 - postcss-modules-local-by-default@4.0.5(postcss@8.5.9): + postcss-modules-local-by-default@4.0.5(postcss@8.5.10): dependencies: - icss-utils: 5.1.0(postcss@8.5.9) - postcss: 8.5.9 + icss-utils: 5.1.0(postcss@8.5.10) + postcss: 8.5.10 postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.0(postcss@8.5.9): + postcss-modules-scope@3.2.0(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 postcss-selector-parser: 6.1.2 postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@6.0.0(postcss@8.5.9): + postcss-safe-parser@6.0.0(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 - postcss-safe-parser@7.0.1(postcss@8.5.9): + postcss-safe-parser@7.0.1(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 - postcss-scss@4.0.9(postcss@8.5.9): + postcss-scss@4.0.9(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 postcss-selector-parser@6.1.2: dependencies: @@ -14613,13 +14613,13 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-sorting@10.0.0(postcss@8.5.9): + postcss-sorting@10.0.0(postcss@8.5.10): dependencies: - postcss: 8.5.9 + postcss: 8.5.10 postcss-value-parser@4.2.0: {} - postcss@8.5.9: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -15352,8 +15352,8 @@ snapshots: stylelint-order@8.1.1(stylelint@17.6.0(typescript@5.9.3)): dependencies: - postcss: 8.5.9 - postcss-sorting: 10.0.0(postcss@8.5.9) + postcss: 8.5.10 + postcss-sorting: 10.0.0(postcss@8.5.10) stylelint: 17.6.0(typescript@5.9.3) stylelint-scss@7.0.0(stylelint@17.6.0(typescript@5.9.3)): @@ -15397,8 +15397,8 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.9 - postcss-safe-parser: 7.0.1(postcss@8.5.9) + postcss: 8.5.10 + postcss-safe-parser: 7.0.1(postcss@8.5.10) postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 string-width: 8.2.0 @@ -15579,14 +15579,14 @@ snapshots: '@types/postcss-modules-local-by-default': 4.0.2 '@types/postcss-modules-scope': 3.0.4 dotenv: 16.4.5 - icss-utils: 5.1.0(postcss@8.5.9) + icss-utils: 5.1.0(postcss@8.5.10) less: 4.2.0 lodash.camelcase: 4.3.0 - postcss: 8.5.9 - postcss-load-config: 3.1.4(postcss@8.5.9) - postcss-modules-extract-imports: 3.1.0(postcss@8.5.9) - postcss-modules-local-by-default: 4.0.5(postcss@8.5.9) - postcss-modules-scope: 3.2.0(postcss@8.5.9) + postcss: 8.5.10 + postcss-load-config: 3.1.4(postcss@8.5.10) + postcss-modules-extract-imports: 3.1.0(postcss@8.5.10) + postcss-modules-local-by-default: 4.0.5(postcss@8.5.10) + postcss-modules-scope: 3.2.0(postcss@8.5.10) reserved-words: 0.1.2 sass: 1.83.0 source-map-js: 1.2.1 @@ -15848,10 +15848,10 @@ snapshots: pathe: 0.2.0 vite: 7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-sass-dts@1.3.37(postcss@8.5.9)(prettier@3.8.2)(sass-embedded@1.99.0)(vite@7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-sass-dts@1.3.37(postcss@8.5.10)(prettier@3.8.2)(sass-embedded@1.99.0)(vite@7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - postcss: 8.5.9 - postcss-js: 4.0.1(postcss@8.5.9) + postcss: 8.5.10 + postcss-js: 4.0.1(postcss@8.5.10) prettier: 3.8.2 sass-embedded: 1.99.0 vite: 7.3.2(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) @@ -15882,7 +15882,7 @@ snapshots: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.9 + postcss: 8.5.10 rollup: 4.46.2 tinyglobby: 0.2.15 optionalDependencies: diff --git a/lede/feeds.conf.default b/lede/feeds.conf.default index 90755bdc19..ceb9de4861 100644 --- a/lede/feeds.conf.default +++ b/lede/feeds.conf.default @@ -1,7 +1,8 @@ src-git packages https://github.com/coolsnowwolf/packages -#src-git luci https://github.com/coolsnowwolf/luci.git -src-git luci https://github.com/coolsnowwolf/luci.git;openwrt-23.05 +#src-git luci https://github.com/coolsnowwolf/luci +#src-git luci https://github.com/coolsnowwolf/luci.git;openwrt-23.05 #src-git luci https://github.com/coolsnowwolf/luci.git;openwrt-24.10 +src-git luci https://github.com/coolsnowwolf/luci.git;openwrt-25.12 src-git routing https://github.com/coolsnowwolf/routing src-git telephony https://github.com/coolsnowwolf/telephony.git #src-git helloworld https://github.com/fw876/helloworld.git diff --git a/mihomo/adapter/outbound/singmux.go b/mihomo/adapter/outbound/singmux.go index 44b03b64f1..0d9233205c 100644 --- a/mihomo/adapter/outbound/singmux.go +++ b/mihomo/adapter/outbound/singmux.go @@ -2,7 +2,6 @@ package outbound import ( "context" - "time" N "github.com/metacubex/mihomo/common/net" "github.com/metacubex/mihomo/component/proxydialer" @@ -29,7 +28,6 @@ type SingMuxOption struct { Padding bool `proxy:"padding,omitempty"` Statistic bool `proxy:"statistic,omitempty"` OnlyTcp bool `proxy:"only-tcp,omitempty"` - TCPTimeout int `proxy:"tcp-timeout,omitempty"` BrutalOpts BrutalOption `proxy:"brutal-opts,omitempty"` } @@ -105,7 +103,7 @@ func NewSingMux(option SingMuxOption, proxy ProxyAdapter) (ProxyAdapter, error) MinStreams: option.MinStreams, MaxStreams: option.MaxStreams, Padding: option.Padding, - TCPTimeout: time.Duration(option.TCPTimeout) * time.Second, + TCPTimeout: C.DefaultTCPTimeout, Brutal: mux.BrutalOptions{ Enabled: option.BrutalOpts.Enabled, SendBPS: StringToBps(option.BrutalOpts.Up), diff --git a/mihomo/adapter/outbound/socks5.go b/mihomo/adapter/outbound/socks5.go index 514223b95c..912856fdf6 100644 --- a/mihomo/adapter/outbound/socks5.go +++ b/mihomo/adapter/outbound/socks5.go @@ -98,8 +98,6 @@ func (ss *Socks5) ListenPacketContext(ctx context.Context, metadata *C.Metadata) if ss.tls { cc := tls.Client(c, ss.tlsConfig) - ctx, cancel := context.WithTimeout(context.Background(), C.DefaultTLSTimeout) - defer cancel() err = cc.HandshakeContext(ctx) c = cc } diff --git a/mihomo/go.mod b/mihomo/go.mod index 82039318ec..9f1f593ffb 100644 --- a/mihomo/go.mod +++ b/mihomo/go.mod @@ -29,7 +29,7 @@ require ( github.com/metacubex/randv2 v0.2.0 github.com/metacubex/restls-client-go v0.1.7 github.com/metacubex/sing v0.5.7 - github.com/metacubex/sing-mux v0.3.7 + github.com/metacubex/sing-mux v0.3.9 github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a github.com/metacubex/sing-shadowsocks v0.2.12 github.com/metacubex/sing-shadowsocks2 v0.2.7 diff --git a/mihomo/go.sum b/mihomo/go.sum index 47786440ce..aa667bd763 100644 --- a/mihomo/go.sum +++ b/mihomo/go.sum @@ -125,8 +125,8 @@ github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58d github.com/metacubex/restls-client-go v0.1.7/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM= github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w= -github.com/metacubex/sing-mux v0.3.7 h1:CnsRQfxFOLNh8gZqbPVFVPNVWgq5paiLksI0tVJI5CY= -github.com/metacubex/sing-mux v0.3.7/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= +github.com/metacubex/sing-mux v0.3.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk= +github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a h1:977o0ZYYbiQAGuOxql7Q6UN3rEy59OyAE0tELq4gZfI= github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE= diff --git a/mihomo/listener/inbound/mux_test.go b/mihomo/listener/inbound/mux_test.go index 841797e66c..96841dbf1b 100644 --- a/mihomo/listener/inbound/mux_test.go +++ b/mihomo/listener/inbound/mux_test.go @@ -29,9 +29,8 @@ func testSingMux(t *testing.T, tunnel *TestTunnel, out outbound.ProxyAdapter) { protocol := protocol t.Run(protocol, func(t *testing.T) { singMuxOption := outbound.SingMuxOption{ - Enabled: true, - Protocol: protocol, - TCPTimeout: 30, // set a timeout value that is slightly larger than the default + Enabled: true, + Protocol: protocol, } out, err := outbound.NewSingMux(singMuxOption, ¬CloseProxyAdapter{out}) if !assert.NoError(t, err) { diff --git a/mihomo/transport/trusttunnel/client.go b/mihomo/transport/trusttunnel/client.go index 32f56f9372..f534e7d727 100644 --- a/mihomo/transport/trusttunnel/client.go +++ b/mihomo/transport/trusttunnel/client.go @@ -155,16 +155,16 @@ func (c *Client) roundTrip(request *http.Request, conn *httpConn) { if err != nil { _ = pipeWriter.CloseWithError(err) _ = pipeReader.CloseWithError(err) - conn.setUp(nil, err) + conn.setup(nil, err) } else if response.StatusCode != http.StatusOK { _ = response.Body.Close() err = fmt.Errorf("unexpected status code: %d", response.StatusCode) _ = pipeWriter.CloseWithError(err) _ = pipeReader.CloseWithError(err) - conn.setUp(nil, err) + conn.setup(nil, err) } else { c.resetHealthCheckTimer() - conn.setUp(response.Body, nil) + conn.setup(response.Body, nil) } }() } diff --git a/mihomo/transport/trusttunnel/protocol.go b/mihomo/transport/trusttunnel/protocol.go index d25f5fe7d2..93374b1605 100644 --- a/mihomo/transport/trusttunnel/protocol.go +++ b/mihomo/transport/trusttunnel/protocol.go @@ -10,6 +10,7 @@ import ( "net/netip" "runtime" "strings" + "sync" "time" "github.com/metacubex/mihomo/common/httputils" @@ -95,6 +96,7 @@ type httpConn struct { writer io.Writer flusher http.Flusher body io.ReadCloser + setupOnce sync.Once created chan struct{} createErr error cancelFn func() @@ -105,19 +107,28 @@ type httpConn struct { deadline *time.Timer } -func (h *httpConn) setUp(body io.ReadCloser, err error) { - h.body = body - h.createErr = err - close(h.created) +func (h *httpConn) setup(body io.ReadCloser, err error) { + h.setupOnce.Do(func() { + h.body = body + h.createErr = err + close(h.created) + }) + if h.createErr != nil && body != nil { // conn already closed before setup + _ = body.Close() + } } func (h *httpConn) waitCreated() error { <-h.created + if h.body != nil { + return nil + } return h.createErr } func (h *httpConn) Close() error { var errorArr []error + h.setup(nil, net.ErrClosed) if closer, ok := h.writer.(io.Closer); ok { errorArr = append(errorArr, closer.Close()) } diff --git a/mihomo/transport/trusttunnel/service.go b/mihomo/transport/trusttunnel/service.go index 9e827ffa2f..1e73b2036d 100644 --- a/mihomo/transport/trusttunnel/service.go +++ b/mihomo/transport/trusttunnel/service.go @@ -167,7 +167,7 @@ func (s *Service) ServeHTTP(writer http.ResponseWriter, request *http.Request) { }, } httputils.SetAddrFromRequest(&conn.NetAddr, request) - conn.setUp(request.Body, nil) + conn.setup(request.Body, nil) firstPacket := buf.NewPacket() destination, err := conn.ReadPacket(firstPacket) if err != nil { @@ -204,7 +204,7 @@ func (s *Service) ServeHTTP(writer http.ResponseWriter, request *http.Request) { }, } httputils.SetAddrFromRequest(&conn.NetAddr, request) - conn.setUp(request.Body, nil) + conn.setup(request.Body, nil) s.icmpHandler.NewICMPConnection(ctx, conn) } case HealthCheckMagicAddress: @@ -227,7 +227,7 @@ func (s *Service) ServeHTTP(writer http.ResponseWriter, request *http.Request) { }, } httputils.SetAddrFromRequest(&conn.NetAddr, request) - conn.setUp(request.Body, nil) + conn.setup(request.Body, nil) _ = s.handler.NewConnection(ctx, N.NewDeadlineConn(conn), M.Metadata{ Protocol: "trusttunnel", Source: M.ParseSocksaddr(request.RemoteAddr), diff --git a/mihomo/transport/xhttp/client.go b/mihomo/transport/xhttp/client.go index b7a33221fe..3e7c726df4 100644 --- a/mihomo/transport/xhttp/client.go +++ b/mihomo/transport/xhttp/client.go @@ -17,6 +17,7 @@ import ( "github.com/metacubex/mihomo/common/httputils" "github.com/metacubex/http" + "github.com/metacubex/http/httptrace" "github.com/metacubex/quic-go" "github.com/metacubex/quic-go/http3" "github.com/metacubex/tls" @@ -344,7 +345,21 @@ func (c *Client) DialStreamOne() (net.Conn, error) { conn := &Conn{writer: pw} - req, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, c.ctx), http.MethodPost, requestURL.String(), pr) + // Use gotConn to detect when TCP connection is established, so we can + // return the conn immediately without waiting for the HTTP response. + // This breaks the deadlock where CDN buffers response headers until the + // server sends body data, but the server waits for our request body, + // which can't be sent because we haven't returned the conn yet. + gotConn := make(chan bool, 1) + + addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx) + ctx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + gotConn <- true + }, + }) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), pr) if err != nil { _ = pr.Close() _ = pw.Close() @@ -360,21 +375,34 @@ func (c *Client) DialStreamOne() (net.Conn, error) { return nil, err } - resp, err := transport.RoundTrip(req) - if err != nil { + wrc := NewWaitReadCloser() + + go func() { + resp, err := transport.RoundTrip(req) + if err != nil { + wrc.CloseWithError(err) + close(gotConn) + return + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _ = resp.Body.Close() + wrc.CloseWithError(fmt.Errorf("xhttp stream-one bad status: %s", resp.Status)) + return + } + wrc.Set(resp.Body) + }() + + if !<-gotConn { + // RoundTrip failed before TCP connected (e.g. DNS failure) _ = pr.Close() _ = pw.Close() httputils.CloseTransport(transport) + var buf [0]byte + _, err = wrc.Read(buf[:]) return nil, err } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - _ = resp.Body.Close() - _ = pr.Close() - _ = pw.Close() - httputils.CloseTransport(transport) - return nil, fmt.Errorf("xhttp stream-one bad status: %s", resp.Status) - } - conn.reader = resp.Body + + conn.reader = wrc conn.onClose = func() { _ = pr.Close() httputils.CloseTransport(transport) @@ -411,8 +439,18 @@ func (c *Client) DialStreamUp() (net.Conn, error) { sessionID := newSessionID() + // Async download: avoid blocking on CDN response header buffering + gotConn := make(chan bool, 1) + + addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx) + downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + gotConn <- true + }, + }) + downloadReq, err := http.NewRequestWithContext( - httputils.NewAddrContext(&conn.NetAddr, c.ctx), + downloadCtx, http.MethodGet, downloadURL.String(), nil, @@ -449,19 +487,36 @@ func (c *Client) DialStreamUp() (net.Conn, error) { } uploadReq.Host = c.cfg.Host - downloadResp, err := downloadTransport.RoundTrip(downloadReq) - if err != nil { + wrc := NewWaitReadCloser() + + go func() { + resp, err := downloadTransport.RoundTrip(downloadReq) + if err != nil { + wrc.CloseWithError(err) + close(gotConn) + return + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + wrc.CloseWithError(fmt.Errorf("xhttp stream-up download bad status: %s", resp.Status)) + return + } + wrc.Set(resp.Body) + }() + + if !<-gotConn { + _ = pr.Close() + _ = pw.Close() httputils.CloseTransport(uploadTransport) httputils.CloseTransport(downloadTransport) + var buf [0]byte + _, err = wrc.Read(buf[:]) return nil, err } - if downloadResp.StatusCode != http.StatusOK { - _ = downloadResp.Body.Close() - httputils.CloseTransport(uploadTransport) - httputils.CloseTransport(downloadTransport) - return nil, fmt.Errorf("xhttp stream-up download bad status: %s", downloadResp.Status) - } + // Start upload after download TCP is connected, so the server has likely + // already processed the GET and created the session. This preserves the + // original ordering (download before upload) while still being async. go func() { resp, err := uploadTransport.RoundTrip(uploadReq) if err != nil { @@ -476,7 +531,7 @@ func (c *Client) DialStreamUp() (net.Conn, error) { } }() - conn.reader = downloadResp.Body + conn.reader = wrc conn.onClose = func() { _ = pr.Close() httputils.CloseTransport(uploadTransport) @@ -518,8 +573,18 @@ func (c *Client) DialPacketUp() (net.Conn, error) { writer.writeCond = sync.Cond{L: &writer.writeMu} conn := &Conn{writer: writer} + // Async download: avoid blocking on CDN response header buffering + gotConn := make(chan bool, 1) + + addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx) + downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + gotConn <- true + }, + }) + downloadReq, err := http.NewRequestWithContext( - httputils.NewAddrContext(&conn.NetAddr, c.ctx), + downloadCtx, http.MethodGet, downloadURL.String(), nil, @@ -536,20 +601,32 @@ func (c *Client) DialPacketUp() (net.Conn, error) { } downloadReq.Host = downloadCfg.Host - resp, err := downloadTransport.RoundTrip(downloadReq) - if err != nil { + wrc := NewWaitReadCloser() + + go func() { + resp, err := downloadTransport.RoundTrip(downloadReq) + if err != nil { + wrc.CloseWithError(err) + close(gotConn) + return + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + wrc.CloseWithError(fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)) + return + } + wrc.Set(resp.Body) + }() + + if !<-gotConn { httputils.CloseTransport(uploadTransport) httputils.CloseTransport(downloadTransport) + var buf [0]byte + _, err = wrc.Read(buf[:]) return nil, err } - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - httputils.CloseTransport(uploadTransport) - httputils.CloseTransport(downloadTransport) - return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status) - } - conn.reader = resp.Body + conn.reader = wrc conn.onClose = func() { // uploadTransport already closed by writer httputils.CloseTransport(downloadTransport) @@ -563,3 +640,59 @@ func newSessionID() string { _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) } + +// WaitReadCloser is an io.ReadCloser that blocks on Read() until the underlying +// ReadCloser is provided via Set(). This enables returning a reader immediately +// while the actual HTTP response body is obtained asynchronously in a goroutine, +// breaking the synchronous RoundTrip deadlock with CDN header buffering. +type WaitReadCloser struct { + wait chan struct{} + once sync.Once + rc io.ReadCloser + err error +} + +func NewWaitReadCloser() *WaitReadCloser { + return &WaitReadCloser{wait: make(chan struct{})} +} + +// Set provides the underlying ReadCloser and unblocks any pending Read calls. +// Must be called at most once. If Close was already called, rc is closed to +// prevent leaks. +func (w *WaitReadCloser) Set(rc io.ReadCloser) { + w.setup(rc, nil) +} + +// CloseWithError records an error and unblocks any pending Read calls. +func (w *WaitReadCloser) CloseWithError(err error) { + w.setup(nil, err) +} + +// setup sets the underlying ReadCloser and error. +func (w *WaitReadCloser) setup(rc io.ReadCloser, err error) { + w.once.Do(func() { + w.rc = rc + w.err = err + close(w.wait) + }) + if w.err != nil && rc != nil { + _ = rc.Close() + } +} + +func (w *WaitReadCloser) Read(b []byte) (int, error) { + <-w.wait + if w.rc == nil { + return 0, w.err + } + return w.rc.Read(b) +} + +func (w *WaitReadCloser) Close() error { + w.setup(nil, net.ErrClosed) + <-w.wait + if w.rc != nil { + return w.rc.Close() + } + return nil +} diff --git a/mihomo/transport/xhttp/server.go b/mihomo/transport/xhttp/server.go index ce71bb1c17..c8b4fa84c0 100644 --- a/mihomo/transport/xhttp/server.go +++ b/mihomo/transport/xhttp/server.go @@ -145,9 +145,24 @@ func (h *requestHandler) getOrCreateSession(sessionID string) *httpSession { s = newHTTPSession(h.scMaxBufferedPosts.Max) h.sessions[sessionID] = s + + // Reap orphan sessions that never become fully connected (e.g. from probing). + // Matches Xray-core's 30-second reaper in upsertSession. + go func() { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + select { + case <-timer.C: + h.deleteSession(sessionID) + case <-s.connected: + } + }() + return s } + + func (h *requestHandler) deleteSession(sessionID string) { h.mu.Lock() defer h.mu.Unlock() @@ -299,11 +314,7 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // stream-up upload: POST /path/{session} if r.Method == http.MethodPost && len(parts) == 1 && h.allowStreamUpUpload() { sessionID := parts[0] - session := h.getSession(sessionID) - if session == nil { - http.Error(w, "unknown xhttp session", http.StatusBadRequest) - return - } + session := h.getOrCreateSession(sessionID) httpSC := newHTTPServerConn(w, r.Body) err := session.uploadQueue.Push(Packet{ @@ -360,11 +371,7 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - session := h.getSession(sessionID) - if session == nil { - http.Error(w, "unknown xhttp session", http.StatusBadRequest) - return - } + session := h.getOrCreateSession(sessionID) if r.ContentLength > int64(h.scMaxEachPostBytes.Max) { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) diff --git a/mihomo/transport/xhttp/upload_queue.go b/mihomo/transport/xhttp/upload_queue.go index 3172a90bfc..b7bff4329e 100644 --- a/mihomo/transport/xhttp/upload_queue.go +++ b/mihomo/transport/xhttp/upload_queue.go @@ -6,6 +6,8 @@ import ( "sync" ) +var ErrQueueTooLarge = errors.New("packet queue is too large") + type Packet struct { Seq uint64 Payload []byte // UploadQueue will hold Payload, so never reuse it after UploadQueue.Push @@ -97,7 +99,7 @@ func (q *UploadQueue) Read(b []byte) (int, error) { q.mu.Unlock() // the "reassembly buffer" is too large, and we want to constrain memory usage somehow. // let's tear down the connection and hope the application retries. - return 0, errors.New("packet queue is too large") + return 0, ErrQueueTooLarge } q.condPushed.Wait() diff --git a/mihomo/transport/xhttp/upload_queue_test.go b/mihomo/transport/xhttp/upload_queue_test.go new file mode 100644 index 0000000000..2f4882c7bc --- /dev/null +++ b/mihomo/transport/xhttp/upload_queue_test.go @@ -0,0 +1,53 @@ +package xhttp + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUploadQueueMaxPackets(t *testing.T) { + q := NewUploadQueue(2) + ch := make(chan struct{}) + go func() { + err := q.Push(Packet{Seq: 0, Payload: []byte{'0'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 1, Payload: []byte{'1'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 2, Payload: []byte{'2'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 4, Payload: []byte{'4'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 5, Payload: []byte{'5'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 6, Payload: []byte{'6'}}) + assert.NoError(t, err) + err = q.Push(Packet{Seq: 7, Payload: []byte{'7'}}) + assert.ErrorIs(t, err, io.ErrClosedPipe) + close(ch) + }() + + buf := make([]byte, 20) + n, err := q.Read(buf) + assert.Equal(t, 1, n) + assert.Equal(t, []byte{'0'}, buf[:n]) + assert.NoError(t, err) + + n, err = q.Read(buf) + assert.Equal(t, 1, n) + assert.Equal(t, []byte{'1'}, buf[:n]) + + n, err = q.Read(buf) + assert.Equal(t, 1, n) + assert.Equal(t, []byte{'2'}, buf[:n]) + + n, err = q.Read(buf) + assert.Equal(t, 0, n) + assert.ErrorIs(t, err, ErrQueueTooLarge) + + err = q.Close() + assert.NoError(t, err) + + <-ch +} diff --git a/openwrt-packages/adguardhome/Makefile b/openwrt-packages/adguardhome/Makefile index 578dedfc7c..7a78c662cf 100644 --- a/openwrt-packages/adguardhome/Makefile +++ b/openwrt-packages/adguardhome/Makefile @@ -4,7 +4,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=adguardhome PKG_VERSION:=0.107.74 -PKG_RELEASE:=3 +PKG_RELEASE:=1 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/AdguardTeam/AdGuardHome/tar.gz/v$(PKG_VERSION)? diff --git a/openwrt-packages/ddns-go/Makefile b/openwrt-packages/ddns-go/Makefile index 939addbeea..77f73a3f05 100644 --- a/openwrt-packages/ddns-go/Makefile +++ b/openwrt-packages/ddns-go/Makefile @@ -8,12 +8,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ddns-go -PKG_VERSION:=6.16.7 +PKG_VERSION:=6.16.8 PKG_RELEASE:=1 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/jeessy2/ddns-go/tar.gz/v$(PKG_VERSION)? -PKG_HASH:=3f30d2aba480b20605951b6bd7e21dd059a2b9804f270ba448aefaa63e4e4158 +PKG_HASH:=1fe5a923fc2ebaf73f46dabf905c307e8149bb33cda5b5d81a962f4cc47bef9c PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/pingtunnel/client.go b/pingtunnel/client.go index dd4d479685..44bb4ef9b8 100644 --- a/pingtunnel/client.go +++ b/pingtunnel/client.go @@ -23,7 +23,8 @@ const ( func NewClient(addr string, server string, target string, timeout int, key int, icmpAddr string, tcpmode int, tcpmode_buffersize int, tcpmode_maxwin int, tcpmode_resend_timems int, tcpmode_compress int, - tcpmode_stat int, open_sock5 int, maxconn int, sock5_filter *func(addr string) bool, cryptoConfig *CryptoConfig) (*Client, error) { + tcpmode_stat int, open_sock5 int, maxconn int, sock5_filter *func(addr string) bool, cryptoConfig *CryptoConfig, + sock5_user string, sock5_pass string) (*Client, error) { var ipaddr *net.UDPAddr var tcpaddr *net.TCPAddr @@ -71,6 +72,8 @@ func NewClient(addr string, server string, target string, timeout int, key int, maxconn: maxconn, pongTime: now, sock5_filter: sock5_filter, + sock5_user: sock5_user, + sock5_pass: sock5_pass, cryptoConfig: cryptoConfig, nextResolveAt: now, resolveRetryBackoff: 2 * time.Second, @@ -101,6 +104,8 @@ type Client struct { open_sock5 int sock5_filter *func(addr string) bool + sock5_user string + sock5_pass string cryptoConfig *CryptoConfig ipaddr *net.UDPAddr @@ -902,7 +907,7 @@ func (p *Client) AcceptSock5Conn(conn *net.TCPConn) { defer p.workResultLock.Done() var err error = nil - if err = network.Sock5HandshakeBy(conn, "", ""); err != nil { + if err = network.Sock5HandshakeBy(conn, p.sock5_user, p.sock5_pass); err != nil { loggo.Error("socks handshake: %s", err) conn.Close() return diff --git a/pingtunnel/cmd/main.go b/pingtunnel/cmd/main.go index 5759bc6656..8809b9c662 100644 --- a/pingtunnel/cmd/main.go +++ b/pingtunnel/cmd/main.go @@ -123,6 +123,12 @@ Usage: -sock5 开启sock5转发,默认0 Turn on sock5 forwarding, default 0 is off + -s5user sock5用户名,默认为空不需要认证 + sock5 username, default is empty and no authentication is required + + -s5pass sock5密码,默认为空不需要认证 + sock5 password, default is empty and no authentication is required + -profile 在指定端口开启性能检测,默认0不开启 Enable performance detection on the specified port. The default 0 is not enabled. @@ -156,6 +162,8 @@ func main() { tcpmode_stat := flag.Int("tcp_stat", 0, "print tcp stat") loglevel := flag.String("loglevel", "info", "log level") open_sock5 := flag.Int("sock5", 0, "sock5 mode") + sock5_user := flag.String("s5user", "", "sock5 username") + sock5_pass := flag.String("s5pass", "", "sock5 password") maxconn := flag.Int("maxconn", 0, "max num of connections") max_process_thread := flag.Int("maxprt", 100, "max process thread in server") max_process_buffer := flag.Int("maxprb", 1000, "max process thread's buffer in server") @@ -296,7 +304,7 @@ func main() { c, err := pingtunnel.NewClient(*listen, *server, *target, *timeout, *key, *icmpListen, *tcpmode, *tcpmode_buffersize, *tcpmode_maxwin, *tcpmode_resend_timems, *tcpmode_compress, - *tcpmode_stat, *open_sock5, *maxconn, &filter, cryptoConfig) + *tcpmode_stat, *open_sock5, *maxconn, &filter, cryptoConfig, *sock5_user, *sock5_pass) if err != nil { loggo.Error("ERROR: %s", err.Error()) return diff --git a/sing-box/common/httpclient/helpers.go b/sing-box/common/httpclient/helpers.go index cffc797198..7cc78cc6e1 100644 --- a/sing-box/common/httpclient/helpers.go +++ b/sing-box/common/httpclient/helpers.go @@ -12,6 +12,8 @@ import ( E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/idna" ) func dialTLS(ctx context.Context, rawDialer N.Dialer, baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string, expectProto string) (net.Conn, error) { @@ -73,6 +75,34 @@ func mustGetBody(request *http.Request) io.ReadCloser { return body } +func requestAuthority(request *http.Request) string { + if request == nil || request.URL == nil || request.URL.Host == "" { + return "" + } + host, port, err := net.SplitHostPort(request.URL.Host) + if err != nil { + host = request.URL.Host + port = "" + } + if port == "" { + if request.URL.Scheme == "http" { + port = "80" + } else { + port = "443" + } + } + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + return host + ":" + port + } + ascii, idnaErr := idna.Lookup.ToASCII(host) + if idnaErr == nil { + host = ascii + } else { + host = strings.ToLower(host) + } + return net.JoinHostPort(host, port) +} + func buildSTDTLSConfig(baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string) (*stdTLS.Config, error) { if baseTLSConfig == nil { return nil, nil diff --git a/sing-box/common/httpclient/helpers_test.go b/sing-box/common/httpclient/helpers_test.go new file mode 100644 index 0000000000..2c451e0a58 --- /dev/null +++ b/sing-box/common/httpclient/helpers_test.go @@ -0,0 +1,51 @@ +package httpclient + +import ( + "net/http" + "net/url" + "testing" +) + +func TestRequestAuthority(t *testing.T) { + testCases := []struct { + name string + url string + expect string + }{ + {name: "https default port", url: "https://example.com/foo", expect: "example.com:443"}, + {name: "http default port", url: "http://example.com/foo", expect: "example.com:80"}, + {name: "https explicit port", url: "https://example.com:8443/foo", expect: "example.com:8443"}, + {name: "https uppercase host", url: "https://EXAMPLE.COM/foo", expect: "example.com:443"}, + {name: "https ipv6 default port", url: "https://[2001:db8::1]/foo", expect: "[2001:db8::1]:443"}, + {name: "https ipv6 explicit port", url: "https://[2001:db8::1]:8443/foo", expect: "[2001:db8::1]:8443"}, + {name: "https ipv4", url: "https://192.0.2.1/foo", expect: "192.0.2.1:443"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + parsed, err := url.Parse(testCase.url) + if err != nil { + t.Fatalf("parse url: %v", err) + } + got := requestAuthority(&http.Request{URL: parsed}) + if got != testCase.expect { + t.Fatalf("got %q, want %q", got, testCase.expect) + } + }) + } + + t.Run("nil request", func(t *testing.T) { + if got := requestAuthority(nil); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) + t.Run("nil URL", func(t *testing.T) { + if got := requestAuthority(&http.Request{}); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) + t.Run("empty host", func(t *testing.T) { + if got := requestAuthority(&http.Request{URL: &url.URL{Scheme: "https"}}); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) +} diff --git a/sing-box/common/httpclient/http2_fallback_transport.go b/sing-box/common/httpclient/http2_fallback_transport.go index 5b16dff187..682b1ebadf 100644 --- a/sing-box/common/httpclient/http2_fallback_transport.go +++ b/sing-box/common/httpclient/http2_fallback_transport.go @@ -6,7 +6,7 @@ import ( "errors" "net" "net/http" - "sync/atomic" + "sync" "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/option" @@ -20,35 +20,47 @@ import ( var errHTTP2Fallback = E.New("fallback to HTTP/1.1") type http2FallbackTransport struct { - h2Transport *http2.Transport - h1Transport *http1Transport - h2Fallback *atomic.Bool + h2Transport *http2.Transport + h1Transport *http1Transport + fallbackAccess sync.RWMutex + fallbackAuthority map[string]struct{} } func newHTTP2FallbackTransport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTP2Options) (*http2FallbackTransport, error) { h1 := newHTTP1Transport(rawDialer, baseTLSConfig) - var fallback atomic.Bool h2Transport, err := ConfigureHTTP2Transport(options) if err != nil { return nil, err } h2Transport.DialTLSContext = func(ctx context.Context, network, addr string, _ *stdTLS.Config) (net.Conn, error) { - conn, dialErr := dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS) - if dialErr != nil { - if errors.Is(dialErr, errHTTP2Fallback) { - fallback.Store(true) - } - return nil, dialErr - } - return conn, nil + return dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS) } return &http2FallbackTransport{ - h2Transport: h2Transport, - h1Transport: h1, - h2Fallback: &fallback, + h2Transport: h2Transport, + h1Transport: h1, + fallbackAuthority: make(map[string]struct{}), }, nil } +func (t *http2FallbackTransport) isH2Fallback(authority string) bool { + if authority == "" { + return false + } + t.fallbackAccess.RLock() + _, found := t.fallbackAuthority[authority] + t.fallbackAccess.RUnlock() + return found +} + +func (t *http2FallbackTransport) markH2Fallback(authority string) { + if authority == "" { + return + } + t.fallbackAccess.Lock() + t.fallbackAuthority[authority] = struct{}{} + t.fallbackAccess.Unlock() +} + func (t *http2FallbackTransport) RoundTrip(request *http.Request) (*http.Response, error) { return t.roundTrip(request, true) } @@ -57,7 +69,8 @@ func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fall if request.URL.Scheme != "https" || requestRequiresHTTP1(request) { return t.h1Transport.RoundTrip(request) } - if t.h2Fallback.Load() { + authority := requestAuthority(request) + if t.isH2Fallback(authority) { if !allowHTTP1Fallback { return nil, errHTTP2Fallback } @@ -70,6 +83,7 @@ func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fall if !errors.Is(err, errHTTP2Fallback) || !allowHTTP1Fallback { return nil, err } + t.markH2Fallback(authority) return t.h1Transport.RoundTrip(cloneRequestForRetry(request)) } diff --git a/sing-box/common/httpclient/http2_fallback_transport_test.go b/sing-box/common/httpclient/http2_fallback_transport_test.go new file mode 100644 index 0000000000..2c2085c863 --- /dev/null +++ b/sing-box/common/httpclient/http2_fallback_transport_test.go @@ -0,0 +1,37 @@ +package httpclient + +import ( + "testing" +) + +func TestHTTP2FallbackAuthorityIsolation(t *testing.T) { + transport := &http2FallbackTransport{fallbackAuthority: make(map[string]struct{})} + + transport.markH2Fallback("a.example:443") + if !transport.isH2Fallback("a.example:443") { + t.Fatal("a.example:443 should be marked") + } + if transport.isH2Fallback("b.example:443") { + t.Fatal("b.example:443 must remain unmarked after marking a.example") + } + + transport.markH2Fallback("b.example:443") + if !transport.isH2Fallback("b.example:443") { + t.Fatal("b.example:443 should be marked after explicit mark") + } + if !transport.isH2Fallback("a.example:443") { + t.Fatal("a.example:443 mark must survive marking another authority") + } +} + +func TestHTTP2FallbackEmptyAuthorityNoOp(t *testing.T) { + transport := &http2FallbackTransport{fallbackAuthority: make(map[string]struct{})} + + transport.markH2Fallback("") + if len(transport.fallbackAuthority) != 0 { + t.Fatalf("empty authority must not be stored, got %d entries", len(transport.fallbackAuthority)) + } + if transport.isH2Fallback("") { + t.Fatal("isH2Fallback must be false for empty authority") + } +} diff --git a/sing-box/common/httpclient/http3_transport.go b/sing-box/common/httpclient/http3_transport.go index 0b8855d7cd..d3eb5bc155 100644 --- a/sing-box/common/httpclient/http3_transport.go +++ b/sing-box/common/httpclient/http3_transport.go @@ -24,13 +24,17 @@ type http3Transport struct { h3Transport *http3.Transport } +type http3BrokenEntry struct { + until time.Time + backoff time.Duration +} + type http3FallbackTransport struct { h3Transport *http3.Transport h2Fallback innerTransport fallbackDelay time.Duration brokenAccess sync.Mutex - brokenUntil time.Time - brokenBackoff time.Duration + broken map[string]http3BrokenEntry } func newHTTP3RoundTripper( @@ -114,6 +118,7 @@ func newHTTP3FallbackTransport( h3Transport: newHTTP3RoundTripper(rawDialer, baseTLSConfig, options), h2Fallback: h2Fallback, fallbackDelay: fallbackDelay, + broken: make(map[string]http3BrokenEntry), }, nil } @@ -138,31 +143,32 @@ func (t *http3FallbackTransport) RoundTrip(request *http.Request) (*http.Respons } func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Response, error) { - if t.h3Broken() { + authority := requestAuthority(request) + if t.h3Broken(authority) { return t.h2FallbackRoundTrip(request) } response, err := t.h3Transport.RoundTripOpt(request, http3.RoundTripOpt{OnlyCachedConn: true}) if err == nil { - t.clearH3Broken() + t.clearH3Broken(authority) return response, nil } if !errors.Is(err, http3.ErrNoCachedConn) { - t.markH3Broken() + t.markH3Broken(authority) return t.h2FallbackRoundTrip(cloneRequestForRetry(request)) } if !requestReplayable(request) { response, err = t.h3Transport.RoundTrip(request) if err == nil { - t.clearH3Broken() + t.clearH3Broken(authority) return response, nil } - t.markH3Broken() + t.markH3Broken(authority) return nil, err } - return t.roundTripHTTP3Race(request) + return t.roundTripHTTP3Race(request, authority) } -func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request) (*http.Response, error) { +func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, authority string) (*http.Response, error) { ctx, cancel := context.WithCancel(request.Context()) defer cancel() type result struct { @@ -215,13 +221,13 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request) (*htt received++ if raceResult.err == nil { if raceResult.h3 { - t.clearH3Broken() + t.clearH3Broken(authority) } drainRemaining() return raceResult.response, nil } if raceResult.h3 { - t.markH3Broken() + t.markH3Broken(authority) h3Err = raceResult.err if goroutines == 1 { goroutines++ @@ -269,29 +275,47 @@ func (t *http3FallbackTransport) Close() error { return t.h3Transport.Close() } -func (t *http3FallbackTransport) h3Broken() bool { +func (t *http3FallbackTransport) h3Broken(authority string) bool { + if authority == "" { + return false + } t.brokenAccess.Lock() defer t.brokenAccess.Unlock() - return !t.brokenUntil.IsZero() && time.Now().Before(t.brokenUntil) + entry, found := t.broken[authority] + if !found { + return false + } + if entry.until.IsZero() || !time.Now().Before(entry.until) { + delete(t.broken, authority) + return false + } + return true } -func (t *http3FallbackTransport) clearH3Broken() { +func (t *http3FallbackTransport) clearH3Broken(authority string) { + if authority == "" { + return + } t.brokenAccess.Lock() - t.brokenUntil = time.Time{} - t.brokenBackoff = 0 + delete(t.broken, authority) t.brokenAccess.Unlock() } -func (t *http3FallbackTransport) markH3Broken() { +func (t *http3FallbackTransport) markH3Broken(authority string) { + if authority == "" { + return + } t.brokenAccess.Lock() defer t.brokenAccess.Unlock() - if t.brokenBackoff == 0 { - t.brokenBackoff = 5 * time.Minute + entry := t.broken[authority] + if entry.backoff == 0 { + entry.backoff = 5 * time.Minute } else { - t.brokenBackoff *= 2 - if t.brokenBackoff > 48*time.Hour { - t.brokenBackoff = 48 * time.Hour + entry.backoff *= 2 + if entry.backoff > 48*time.Hour { + entry.backoff = 48 * time.Hour } } - t.brokenUntil = time.Now().Add(t.brokenBackoff) + entry.until = time.Now().Add(entry.backoff) + t.broken[authority] = entry } diff --git a/sing-box/common/httpclient/http3_transport_test.go b/sing-box/common/httpclient/http3_transport_test.go new file mode 100644 index 0000000000..600e88db06 --- /dev/null +++ b/sing-box/common/httpclient/http3_transport_test.go @@ -0,0 +1,99 @@ +//go:build with_quic + +package httpclient + +import ( + "testing" + "time" +) + +func TestHTTP3BrokenAuthorityIsolation(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("a.example:443") + if !transport.h3Broken("a.example:443") { + t.Fatal("a.example:443 should be broken after mark") + } + if transport.h3Broken("b.example:443") { + t.Fatal("b.example:443 must not be affected by marking a.example") + } +} + +func TestHTTP3BrokenBackoffPerAuthority(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 5*time.Minute { + t.Fatalf("first mark should set backoff to 5m, got %v", transport.broken["a.example:443"].backoff) + } + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 10*time.Minute { + t.Fatalf("second mark should double backoff to 10m, got %v", transport.broken["a.example:443"].backoff) + } + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 20*time.Minute { + t.Fatalf("third mark should double to 20m, got %v", transport.broken["a.example:443"].backoff) + } + + if _, found := transport.broken["b.example:443"]; found { + t.Fatal("marking a.example must not leak into b.example backoff state") + } + + transport.markH3Broken("b.example:443") + if transport.broken["b.example:443"].backoff != 5*time.Minute { + t.Fatalf("b.example first mark should start at 5m independent of a.example, got %v", transport.broken["b.example:443"].backoff) + } +} + +func TestHTTP3BrokenBackoffCap(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.broken["a.example:443"] = http3BrokenEntry{backoff: 48 * time.Hour, until: time.Now().Add(48 * time.Hour)} + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 48*time.Hour { + t.Fatalf("backoff must cap at 48h, got %v", transport.broken["a.example:443"].backoff) + } +} + +func TestHTTP3BrokenClearDeletesEntry(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("a.example:443") + transport.markH3Broken("b.example:443") + transport.clearH3Broken("a.example:443") + + if _, found := transport.broken["a.example:443"]; found { + t.Fatal("clearH3Broken must delete the entry") + } + if !transport.h3Broken("b.example:443") { + t.Fatal("clearing a.example must not affect b.example") + } +} + +func TestHTTP3BrokenExpiredEntryGarbageCollected(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.broken["a.example:443"] = http3BrokenEntry{ + backoff: 5 * time.Minute, + until: time.Now().Add(-time.Second), + } + if transport.h3Broken("a.example:443") { + t.Fatal("expired entry must report not broken") + } + if _, found := transport.broken["a.example:443"]; found { + t.Fatal("expired entry must be garbage-collected on read") + } +} + +func TestHTTP3BrokenEmptyAuthorityNoOp(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("") + if len(transport.broken) != 0 { + t.Fatalf("markH3Broken must ignore empty authority, got %d entries", len(transport.broken)) + } + if transport.h3Broken("") { + t.Fatal("h3Broken must return false for empty authority") + } + transport.clearH3Broken("") +} diff --git a/sing-box/common/httpclient/manager.go b/sing-box/common/httpclient/manager.go index 2b4f9d5be3..614e4f83bd 100644 --- a/sing-box/common/httpclient/manager.go +++ b/sing-box/common/httpclient/manager.go @@ -69,21 +69,25 @@ func (m *Manager) Start(stage adapter.StartStage) error { return E.Cause(err, "resolve default http client") } m.defaultTransport = sharedTransport - } else if m.defaultTransportFallback != nil { - transport, err := m.defaultTransportFallback() - if err != nil { - return E.Cause(err, "create default http client") - } - m.trackTransport(transport) - m.defaultTransport = &sharedManagedTransport{ - managed: transport, - shared: &sharedState{}, - } } return nil } func (m *Manager) DefaultTransport() adapter.HTTPTransport { + m.access.Lock() + defer m.access.Unlock() + if m.defaultTransport == nil && m.defaultTransportFallback != nil { + transport, err := m.defaultTransportFallback() + if err != nil { + m.logger.Error(E.Cause(err, "create default http client")) + return nil + } + m.managedTransports = append(m.managedTransports, transport) + m.defaultTransport = &sharedManagedTransport{ + managed: transport, + shared: &sharedState{}, + } + } if m.defaultTransport == nil { return nil } diff --git a/sing-box/common/tls/apple_client_platform.go b/sing-box/common/tls/apple_client_platform.go index 9e7d6e73a2..38cef976f3 100644 --- a/sing-box/common/tls/apple_client_platform.go +++ b/sing-box/common/tls/apple_client_platform.go @@ -102,20 +102,7 @@ func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) return nil, err } - var state C.box_apple_tls_state_t - stateOK := C.box_apple_tls_client_copy_state(client, &state, &errorPtr) - if !bool(stateOK) { - C.box_apple_tls_client_cancel(client) - C.box_apple_tls_client_free(client) - if errorPtr != nil { - defer C.free(unsafe.Pointer(errorPtr)) - return nil, E.New(C.GoString(errorPtr)) - } - return nil, E.New("apple TLS: read metadata") - } - defer C.box_apple_tls_state_free(&state) - - connectionState, rawCerts, err := parseAppleTLSState(&state) + connectionState, rawCerts, err := copyAppleTLSConnectionState(client) if err != nil { C.box_apple_tls_client_cancel(client) C.box_apple_tls_client_free(client) @@ -140,6 +127,23 @@ func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) const appleTLSHandshakePollInterval = 100 * time.Millisecond +func copyAppleTLSConnectionState(client *C.box_apple_tls_client_t) (tls.ConnectionState, [][]byte, error) { + var ( + state C.box_apple_tls_state_t + errorPtr *C.char + ) + stateOK := C.box_apple_tls_client_copy_state(client, &state, &errorPtr) + if !bool(stateOK) { + if errorPtr != nil { + defer C.free(unsafe.Pointer(errorPtr)) + return tls.ConnectionState{}, nil, E.New(C.GoString(errorPtr)) + } + return tls.ConnectionState{}, nil, E.New("apple TLS: read metadata") + } + defer C.box_apple_tls_state_free(&state) + return parseAppleTLSState(&state) +} + func waitAppleTLSClientReady(ctx context.Context, client *C.box_apple_tls_client_t) error { for { if err := ctx.Err(); err != nil { diff --git a/sing-box/common/tls/apple_client_platform_darwin.m b/sing-box/common/tls/apple_client_platform_darwin.m index c4a6c19f67..7ce49158c4 100644 --- a/sing-box/common/tls/apple_client_platform_darwin.m +++ b/sing-box/common/tls/apple_client_platform_darwin.m @@ -113,44 +113,83 @@ static void box_set_error_from_nw_error(char **error_out, nw_error_t error) { CFRelease(cfError); } -static char *box_apple_tls_metadata_copy_negotiated_protocol(sec_protocol_metadata_t metadata) { - static box_sec_protocol_metadata_string_accessor_f copy_fn; - static box_sec_protocol_metadata_string_accessor_f get_fn; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - copy_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_copy_negotiated_protocol"); - get_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_get_negotiated_protocol"); +static bool box_apple_tls_metadata_load_owned_string( + sec_protocol_metadata_t metadata, + const char *copy_symbol, + const char *get_symbol, + box_sec_protocol_metadata_string_accessor_f *copy_fn, + box_sec_protocol_metadata_string_accessor_f *get_fn, + dispatch_once_t *once_token, + char **value_out, + char **error_out +) { + dispatch_once(once_token, ^{ + *copy_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, copy_symbol); + *get_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, get_symbol); }); - if (copy_fn != NULL) { - return (char *)copy_fn(metadata); - } - if (get_fn != NULL) { - const char *protocol = get_fn(metadata); - if (protocol != NULL) { - return strdup(protocol); + if (*copy_fn != NULL) { + const char *value = (*copy_fn)(metadata); + if (value == NULL) { + *value_out = NULL; + return true; } + char *owned = strdup(value); + free((void *)value); + if (owned == NULL) { + box_set_error_message(error_out, "apple TLS: out of memory"); + return false; + } + *value_out = owned; + return true; } - return NULL; + if (*get_fn != NULL) { + const char *value = (*get_fn)(metadata); + if (value == NULL) { + *value_out = NULL; + return true; + } + char *owned = strdup(value); + if (owned == NULL) { + box_set_error_message(error_out, "apple TLS: out of memory"); + return false; + } + *value_out = owned; + return true; + } + *value_out = NULL; + return true; } -static char *box_apple_tls_metadata_copy_server_name(sec_protocol_metadata_t metadata) { +static bool box_apple_tls_metadata_load_negotiated_protocol(sec_protocol_metadata_t metadata, char **value_out, char **error_out) { static box_sec_protocol_metadata_string_accessor_f copy_fn; static box_sec_protocol_metadata_string_accessor_f get_fn; static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - copy_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_copy_server_name"); - get_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_get_server_name"); - }); - if (copy_fn != NULL) { - return (char *)copy_fn(metadata); - } - if (get_fn != NULL) { - const char *server_name = get_fn(metadata); - if (server_name != NULL) { - return strdup(server_name); - } - } - return NULL; + return box_apple_tls_metadata_load_owned_string( + metadata, + "sec_protocol_metadata_copy_negotiated_protocol", + "sec_protocol_metadata_get_negotiated_protocol", + ©_fn, + &get_fn, + &onceToken, + value_out, + error_out + ); +} + +static bool box_apple_tls_metadata_load_server_name(sec_protocol_metadata_t metadata, char **value_out, char **error_out) { + static box_sec_protocol_metadata_string_accessor_f copy_fn; + static box_sec_protocol_metadata_string_accessor_f get_fn; + static dispatch_once_t onceToken; + return box_apple_tls_metadata_load_owned_string( + metadata, + "sec_protocol_metadata_copy_server_name", + "sec_protocol_metadata_get_server_name", + ©_fn, + &get_fn, + &onceToken, + value_out, + error_out + ); } static NSArray *box_split_lines(const char *content, size_t content_len) { @@ -254,61 +293,22 @@ static nw_connection_t box_apple_tls_create_connection(int connected_socket, nw_ return create_fn(connected_socket, parameters); } -static bool box_apple_tls_state_copy(const box_apple_tls_state_t *source, box_apple_tls_state_t *destination) { - memset(destination, 0, sizeof(box_apple_tls_state_t)); - destination->version = source->version; - destination->cipher_suite = source->cipher_suite; - if (source->alpn != NULL) { - destination->alpn = strdup(source->alpn); - if (destination->alpn == NULL) { - goto oom; - } - } - if (source->server_name != NULL) { - destination->server_name = strdup(source->server_name); - if (destination->server_name == NULL) { - goto oom; - } - } - if (source->peer_cert_chain_len > 0) { - destination->peer_cert_chain = malloc(source->peer_cert_chain_len); - if (destination->peer_cert_chain == NULL) { - goto oom; - } - memcpy(destination->peer_cert_chain, source->peer_cert_chain, source->peer_cert_chain_len); - destination->peer_cert_chain_len = source->peer_cert_chain_len; - } - return true; - -oom: - box_apple_tls_state_reset(destination); - return false; -} - -static bool box_apple_tls_state_load(nw_connection_t connection, box_apple_tls_state_t *state, char **error_out) { +static bool box_apple_tls_state_load_sec_metadata(sec_protocol_metadata_t sec_metadata, box_apple_tls_state_t *state, char **error_out) { box_apple_tls_state_reset(state); - if (connection == nil) { - box_set_error_message(error_out, "apple TLS: invalid client"); - return false; - } - - nw_protocol_definition_t tls_definition = nw_protocol_copy_tls_definition(); - nw_protocol_metadata_t metadata = nw_connection_copy_protocol_metadata(connection, tls_definition); - if (metadata == NULL || !nw_protocol_metadata_is_tls(metadata)) { - box_set_error_message(error_out, "apple TLS: metadata unavailable"); - return false; - } - - sec_protocol_metadata_t sec_metadata = nw_tls_copy_sec_protocol_metadata(metadata); if (sec_metadata == NULL) { box_set_error_message(error_out, "apple TLS: metadata unavailable"); return false; } - state->version = (uint16_t)sec_protocol_metadata_get_negotiated_tls_protocol_version(sec_metadata); state->cipher_suite = (uint16_t)sec_protocol_metadata_get_negotiated_tls_ciphersuite(sec_metadata); - state->alpn = box_apple_tls_metadata_copy_negotiated_protocol(sec_metadata); - state->server_name = box_apple_tls_metadata_copy_server_name(sec_metadata); + if (!box_apple_tls_metadata_load_negotiated_protocol(sec_metadata, &state->alpn, error_out)) { + box_apple_tls_state_reset(state); + return false; + } + if (!box_apple_tls_metadata_load_server_name(sec_metadata, &state->server_name, error_out)) { + box_apple_tls_state_reset(state); + return false; + } NSMutableData *chain_data = [NSMutableData data]; sec_protocol_metadata_access_peer_certificate_chain(sec_metadata, ^(sec_certificate_t certificate) { @@ -340,6 +340,48 @@ static bool box_apple_tls_state_load(nw_connection_t connection, box_apple_tls_s return true; } +static bool box_apple_tls_client_capture_state(box_apple_tls_client_t *client, sec_protocol_metadata_t metadata, char **error_out) { + box_apple_tls_state_t loaded_state = {0}; + if (!box_apple_tls_state_load_sec_metadata(metadata, &loaded_state, error_out)) { + return false; + } + box_apple_tls_state_reset(&client->state); + client->state = loaded_state; + memset(&loaded_state, 0, sizeof(box_apple_tls_state_t)); + return true; +} + +static bool box_apple_tls_state_copy(const box_apple_tls_state_t *source, box_apple_tls_state_t *destination) { + memset(destination, 0, sizeof(box_apple_tls_state_t)); + destination->version = source->version; + destination->cipher_suite = source->cipher_suite; + if (source->alpn != NULL) { + destination->alpn = strdup(source->alpn); + if (destination->alpn == NULL) { + goto oom; + } + } + if (source->server_name != NULL) { + destination->server_name = strdup(source->server_name); + if (destination->server_name == NULL) { + goto oom; + } + } + if (source->peer_cert_chain_len > 0) { + destination->peer_cert_chain = malloc(source->peer_cert_chain_len); + if (destination->peer_cert_chain == NULL) { + goto oom; + } + memcpy(destination->peer_cert_chain, source->peer_cert_chain, source->peer_cert_chain_len); + destination->peer_cert_chain_len = source->peer_cert_chain_len; + } + return true; + +oom: + box_apple_tls_state_reset(destination); + return false; +} + box_apple_tls_client_t *box_apple_tls_client_create( int connected_socket, const char *server_name, @@ -388,15 +430,19 @@ box_apple_tls_client_t *box_apple_tls_client_create( sec_protocol_options_add_tls_application_protocol(sec_options, protocol.UTF8String); } sec_protocol_options_set_peer_authentication_required(sec_options, !insecure); - if (insecure) { - sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) { + sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) { + if (client->ready_error == NULL) { + char *local_error = NULL; + if (!box_apple_tls_client_capture_state(client, metadata, &local_error)) { + client->ready_error = local_error; + } + } + if (insecure) { complete(true); - }, box_apple_tls_client_queue(client)); - } else if (verifyDate != nil || anchors.count > 0 || anchor_only) { - sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) { - complete(box_evaluate_trust(trust, anchors, anchor_only, verifyDate)); - }, box_apple_tls_client_queue(client)); - } + return; + } + complete(box_evaluate_trust(trust, anchors, anchor_only, verifyDate)); + }, box_apple_tls_client_queue(client)); }, NW_PARAMETERS_DEFAULT_CONFIGURATION); nw_connection_t connection = box_apple_tls_create_connection(connected_socket, parameters); @@ -420,7 +466,11 @@ box_apple_tls_client_t *box_apple_tls_client_create( switch (state) { case nw_connection_state_ready: if (!atomic_load(&client->ready_done)) { - atomic_store(&client->ready, box_apple_tls_state_load(connection, &client->state, &client->ready_error)); + bool state_loaded = client->state.version != 0; + if (!state_loaded && client->ready_error == NULL) { + box_set_error_message(&client->ready_error, "apple TLS: metadata unavailable"); + } + atomic_store(&client->ready, state_loaded); atomic_store(&client->ready_done, true); dispatch_semaphore_signal(box_apple_tls_ready_semaphore(client)); } diff --git a/sing-box/common/tls/client.go b/sing-box/common/tls/client.go index 00020ee2c9..22215176e0 100644 --- a/sing-box/common/tls/client.go +++ b/sing-box/common/tls/client.go @@ -6,6 +6,7 @@ import ( "errors" "net" "os" + "strings" "github.com/sagernet/sing-box/common/badtls" "github.com/sagernet/sing-box/common/tlsspoof" @@ -30,9 +31,12 @@ func parseTLSSpoofOptions(serverName string, options option.OutboundTLSOptions) if !tlsspoof.PlatformSupported { return "", 0, E.New("`spoof` is not supported on this platform") } - if options.DisableSNI || serverName == "" { + if options.DisableSNI || serverName == "" || M.ParseAddr(serverName).IsValid() { return "", 0, E.New("`spoof` requires TLS ClientHello with SNI") } + if strings.EqualFold(options.Spoof, serverName) { + return "", 0, E.New("`spoof` must differ from `server_name`") + } method, err := tlsspoof.ParseMethod(options.SpoofMethod) if err != nil { return "", 0, err @@ -48,7 +52,12 @@ func applyTLSSpoof(conn net.Conn, spoof string, method tlsspoof.Method) (net.Con if err != nil { return nil, err } - return tlsspoof.NewConn(conn, spoofer, spoof), nil + spoofConn, err := tlsspoof.NewConn(conn, spoofer, spoof) + if err != nil { + spoofer.Close() + return nil, err + } + return spoofConn, nil } func NewDialerFromOptions(ctx context.Context, logger logger.ContextLogger, dialer N.Dialer, serverAddress string, options option.OutboundTLSOptions) (N.Dialer, error) { diff --git a/sing-box/common/tls/client_test.go b/sing-box/common/tls/client_test.go new file mode 100644 index 0000000000..5bc939e29e --- /dev/null +++ b/sing-box/common/tls/client_test.go @@ -0,0 +1,154 @@ +package tls + +import ( + "context" + "crypto/tls" + "net" + "testing" + + tf "github.com/sagernet/sing-box/common/tlsfragment" + "github.com/sagernet/sing-box/common/tlsspoof" + "github.com/sagernet/sing-box/option" + + "github.com/stretchr/testify/require" +) + +func TestParseTLSSpoofOptions_Disabled(t *testing.T) { + t.Parallel() + spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{}) + require.NoError(t, err) + require.Empty(t, spoof) + require.Equal(t, tlsspoof.MethodWrongSequence, method) +} + +func TestParseTLSSpoofOptions_MethodWithoutSpoof(t *testing.T) { + t.Parallel() + _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ + SpoofMethod: tlsspoof.MethodNameWrongChecksum, + }) + require.Error(t, err) +} + +func TestParseTLSSpoofOptions_IPLiteralRejected(t *testing.T) { + t.Parallel() + _, _, err := parseTLSSpoofOptions("1.2.3.4", option.OutboundTLSOptions{ + Spoof: "example.com", + }) + require.Error(t, err) +} + +func TestParseTLSSpoofOptions_EmptyServerNameRejected(t *testing.T) { + t.Parallel() + _, _, err := parseTLSSpoofOptions("", option.OutboundTLSOptions{ + Spoof: "example.com", + }) + require.Error(t, err) +} + +func TestParseTLSSpoofOptions_DisableSNIRejected(t *testing.T) { + t.Parallel() + _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ + Spoof: "decoy.com", + DisableSNI: true, + }) + require.Error(t, err) +} + +// TestParseTLSSpoofOptions_RejectsSameSNI is the primary regression test for +// the "spoofed packet contains the original SNI" bug report: when a user +// configures spoof equal to server_name, the rewriter produces a byte-identical +// record, so the fake and real ClientHellos on the wire look the same. Reject +// at parse time. +func TestParseTLSSpoofOptions_RejectsSameSNI(t *testing.T) { + t.Parallel() + _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ + Spoof: "example.com", + }) + require.Error(t, err) + + _, _, err = parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ + Spoof: "EXAMPLE.com", + }) + require.Error(t, err, "comparison must be case-insensitive") +} + +func TestParseTLSSpoofOptions_UnknownMethodRejected(t *testing.T) { + t.Parallel() + _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ + Spoof: "decoy.com", + SpoofMethod: "nonsense", + }) + require.Error(t, err) +} + +func TestParseTLSSpoofOptions_DistinctSNIAccepted(t *testing.T) { + t.Parallel() + if !tlsspoof.PlatformSupported { + t.Skip("tlsspoof not supported on this platform") + } + spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ + Spoof: "decoy.com", + SpoofMethod: tlsspoof.MethodNameWrongSequence, + }) + require.NoError(t, err) + require.Equal(t, "decoy.com", spoof) + require.Equal(t, tlsspoof.MethodWrongSequence, method) +} + +// The following tests guard the wrap gate in STDClientConfig.Client(): +// tf.Conn must wrap the underlying connection whenever either `fragment` or +// `record_fragment` is set, so that TLS fragmentation coexists with features +// like tls_spoof that layer on top of tf.Conn. + +func newSTDClientConfigForGateTest(fragment, recordFragment bool) *STDClientConfig { + return &STDClientConfig{ + ctx: context.Background(), + config: &tls.Config{ServerName: "example.com", InsecureSkipVerify: true}, + fragment: fragment, + recordFragment: recordFragment, + } +} + +func TestSTDClient_Client_NoFragment_DoesNotWrap(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newSTDClientConfigForGateTest(false, false).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.False(t, isTF, "no fragment flags: must not wrap with tf.Conn") +} + +func TestSTDClient_Client_FragmentOnly_Wraps(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newSTDClientConfigForGateTest(true, false).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.True(t, isTF, "fragment=true: must wrap with tf.Conn") +} + +func TestSTDClient_Client_RecordFragmentOnly_Wraps(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newSTDClientConfigForGateTest(false, true).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.True(t, isTF, "record_fragment=true: must wrap with tf.Conn") +} + +func TestSTDClient_Client_BothFragment_Wraps(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newSTDClientConfigForGateTest(true, true).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.True(t, isTF, "both fragment flags: must wrap with tf.Conn") +} diff --git a/sing-box/common/tls/std_client.go b/sing-box/common/tls/std_client.go index f38981c687..031a256f7d 100644 --- a/sing-box/common/tls/std_client.go +++ b/sing-box/common/tls/std_client.go @@ -75,7 +75,7 @@ func (c *STDClientConfig) STDConfig() (*STDConfig, error) { } func (c *STDClientConfig) Client(conn net.Conn) (Conn, error) { - if c.recordFragment { + if c.fragment || c.recordFragment { conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay) } conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod) diff --git a/sing-box/common/tls/utls_client.go b/sing-box/common/tls/utls_client.go index a8b91973c2..1cc41554fa 100644 --- a/sing-box/common/tls/utls_client.go +++ b/sing-box/common/tls/utls_client.go @@ -83,7 +83,7 @@ func (c *UTLSClientConfig) STDConfig() (*STDConfig, error) { } func (c *UTLSClientConfig) Client(conn net.Conn) (Conn, error) { - if c.recordFragment { + if c.fragment || c.recordFragment { conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay) } conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod) diff --git a/sing-box/common/tls/utls_client_test.go b/sing-box/common/tls/utls_client_test.go new file mode 100644 index 0000000000..48c1e327ec --- /dev/null +++ b/sing-box/common/tls/utls_client_test.go @@ -0,0 +1,73 @@ +//go:build with_utls + +package tls + +import ( + "context" + "net" + "testing" + + tf "github.com/sagernet/sing-box/common/tlsfragment" + + utls "github.com/metacubex/utls" + "github.com/stretchr/testify/require" +) + +// Guards the wrap gate in UTLSClientConfig.Client(): tf.Conn must wrap the +// underlying connection whenever either `fragment` or `record_fragment` is +// set. Mirrors the STDClientConfig gate tests to keep both code paths in +// lockstep. + +func newUTLSClientConfigForGateTest(fragment, recordFragment bool) *UTLSClientConfig { + return &UTLSClientConfig{ + ctx: context.Background(), + config: &utls.Config{ServerName: "example.com", InsecureSkipVerify: true}, + id: utls.HelloChrome_Auto, + fragment: fragment, + recordFragment: recordFragment, + } +} + +func TestUTLSClient_Client_NoFragment_DoesNotWrap(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newUTLSClientConfigForGateTest(false, false).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.False(t, isTF, "no fragment flags: must not wrap with tf.Conn") +} + +func TestUTLSClient_Client_FragmentOnly_Wraps(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newUTLSClientConfigForGateTest(true, false).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.True(t, isTF, "fragment=true: must wrap with tf.Conn") +} + +func TestUTLSClient_Client_RecordFragmentOnly_Wraps(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newUTLSClientConfigForGateTest(false, true).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.True(t, isTF, "record_fragment=true: must wrap with tf.Conn") +} + +func TestUTLSClient_Client_BothFragment_Wraps(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + wrapped, err := newUTLSClientConfigForGateTest(true, true).Client(client) + require.NoError(t, err) + _, isTF := wrapped.NetConn().(*tf.Conn) + require.True(t, isTF, "both fragment flags: must wrap with tf.Conn") +} diff --git a/sing-box/common/tlsspoof/README.md b/sing-box/common/tlsspoof/README.md new file mode 100644 index 0000000000..de684e15cd --- /dev/null +++ b/sing-box/common/tlsspoof/README.md @@ -0,0 +1,3 @@ +# tls spoof + +idea from https://github.com/therealaleph/sni-spoofing-rust diff --git a/sing-box/common/tlsspoof/client_hello.go b/sing-box/common/tlsspoof/client_hello.go index 0ca7c5a9f2..abdfa31753 100644 --- a/sing-box/common/tlsspoof/client_hello.go +++ b/sing-box/common/tlsspoof/client_hello.go @@ -1,86 +1,37 @@ package tlsspoof import ( - "encoding/binary" + "bytes" + "context" + "crypto/tls" - tf "github.com/sagernet/sing-box/common/tlsfragment" + "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" ) -const ( - recordLengthOffset = 3 - handshakeLengthOffset = 6 -) - -// server_name extension layout (RFC 6066 §3). Offsets are relative to the -// SNI host name (index returned by the parser): -// -// ... uint16 extension_type = 0x0000 (host_name - 9) -// ... uint16 extension_data_length (host_name - 7) -// ... uint16 server_name_list_length (host_name - 5) -// ... uint8 name_type = host_name (host_name - 3) -// ... uint16 host_name_length (host_name - 2) -// sni host_name (host_name) -const ( - extensionDataLengthOffsetFromSNI = -7 - listLengthOffsetFromSNI = -5 - hostNameLengthOffsetFromSNI = -2 -) - -func rewriteSNI(record []byte, fakeSNI string) ([]byte, error) { - if len(fakeSNI) > 0xFFFF { - return nil, E.New("fake SNI too long: ", len(fakeSNI), " bytes") +// buildFakeClientHello drives crypto/tls against a write-only in-memory conn +// to capture a generated ClientHello. CurvePreferences pins classical groups +// to suppress Go's default X25519MLKEM768 hybrid key share; without this the +// post-quantum public key alone (~1184 bytes) pushes the record past one MSS, +// and middleboxes do not reassemble fragmented ClientHellos. The handshake +// error is discarded because the stub conn's Read returns immediately. +func buildFakeClientHello(sni string) ([]byte, error) { + if sni == "" { + return nil, E.New("empty sni") } - serverName := tf.IndexTLSServerName(record) - if serverName == nil { - return nil, E.New("not a ClientHello with SNI") + var buf bytes.Buffer + tlsConn := tls.Client(bufio.NewWriteOnlyConn(&buf), &tls.Config{ + ServerName: sni, + // Order matches what browsers advertised before post-quantum. + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384}, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + NextProtos: []string{"h2", "http/1.1"}, + InsecureSkipVerify: true, + }) + _ = tlsConn.HandshakeContext(context.Background()) + if buf.Len() == 0 { + return nil, E.New("tls ClientHello not produced") } - - delta := len(fakeSNI) - serverName.Length - out := make([]byte, len(record)+delta) - copy(out, record[:serverName.Index]) - copy(out[serverName.Index:], fakeSNI) - copy(out[serverName.Index+len(fakeSNI):], record[serverName.Index+serverName.Length:]) - - err := patchUint16(out, recordLengthOffset, delta) - if err != nil { - return nil, E.Cause(err, "patch record length") - } - err = patchUint24(out, handshakeLengthOffset, delta) - if err != nil { - return nil, E.Cause(err, "patch handshake length") - } - for _, off := range []int{ - serverName.ExtensionsListLengthIndex, - serverName.Index + extensionDataLengthOffsetFromSNI, - serverName.Index + listLengthOffsetFromSNI, - serverName.Index + hostNameLengthOffsetFromSNI, - } { - err = patchUint16(out, off, delta) - if err != nil { - return nil, E.Cause(err, "patch length at offset ", off) - } - } - return out, nil -} - -func patchUint16(data []byte, offset, delta int) error { - patched := int(binary.BigEndian.Uint16(data[offset:])) + delta - if patched < 0 || patched > 0xFFFF { - return E.New("uint16 out of range: ", patched) - } - binary.BigEndian.PutUint16(data[offset:], uint16(patched)) - return nil -} - -func patchUint24(data []byte, offset, delta int) error { - original := int(data[offset])<<16 | int(data[offset+1])<<8 | int(data[offset+2]) - patched := original + delta - if patched < 0 || patched > 0xFFFFFF { - return E.New("uint24 out of range: ", patched) - } - data[offset] = byte(patched >> 16) - data[offset+1] = byte(patched >> 8) - data[offset+2] = byte(patched) - return nil + return buf.Bytes(), nil } diff --git a/sing-box/common/tlsspoof/client_hello_test.go b/sing-box/common/tlsspoof/client_hello_test.go index 746d0482ad..3eb7a2e040 100644 --- a/sing-box/common/tlsspoof/client_hello_test.go +++ b/sing-box/common/tlsspoof/client_hello_test.go @@ -1,8 +1,9 @@ package tlsspoof import ( + "bytes" "encoding/binary" - "encoding/hex" + "strings" "testing" tf "github.com/sagernet/sing-box/common/tlsfragment" @@ -10,70 +11,73 @@ import ( "github.com/stretchr/testify/require" ) -// realClientHello is a captured Chrome ClientHello for github.com, -// reused from common/tlsfragment/index_test.go. -const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100" +// x25519MLKEM768 is the IANA code point for the post-quantum hybrid named +// group (0x11EC). The fake ClientHello must never carry it — its 1184-byte +// key share is the reason kernel-generated ClientHellos exceed one MSS, and +// the reason this builder has to force CurvePreferences. +const x25519MLKEM768 uint16 = 0x11EC -func decodeClientHello(t *testing.T) []byte { - t.Helper() - payload, err := hex.DecodeString(realClientHello) - require.NoError(t, err) - return payload -} - -func assertConsistent(t *testing.T, payload []byte, expectedSNI string) { - t.Helper() - serverName := tf.IndexTLSServerName(payload) - require.NotNil(t, serverName, "parser should find SNI in rewritten payload") - require.Equal(t, expectedSNI, serverName.ServerName) - require.Equal(t, expectedSNI, string(payload[serverName.Index:serverName.Index+serverName.Length])) - // Record length must equal len(payload) - 5. - recordLen := binary.BigEndian.Uint16(payload[3:5]) - require.Equal(t, len(payload)-5, int(recordLen), "record length must equal payload - 5") - // Handshake length must equal len(payload) - 5 - 4. - handshakeLen := int(payload[6])<<16 | int(payload[7])<<8 | int(payload[8]) - require.Equal(t, len(payload)-5-4, handshakeLen, "handshake length must equal payload - 9") -} - -func TestRewriteSNI_ShorterReplacement(t *testing.T) { +func TestBuildFakeClientHello_ParsesWithSNI(t *testing.T) { t.Parallel() - payload := decodeClientHello(t) - out, err := rewriteSNI(payload, "a.io") + record, err := buildFakeClientHello("example.com") require.NoError(t, err) - require.Len(t, out, len(payload)-6) // original "github.com" is 10 bytes, "a.io" is 4 bytes. - assertConsistent(t, out, "a.io") + + serverName := tf.IndexTLSServerName(record) + require.NotNil(t, serverName, "output must parse as a ClientHello") + require.Equal(t, "example.com", serverName.ServerName) + + recordLen := binary.BigEndian.Uint16(record[3:5]) + require.Equal(t, len(record)-5, int(recordLen), + "record length header must match on-wire record size") + handshakeLen := int(record[6])<<16 | int(record[7])<<8 | int(record[8]) + require.Equal(t, len(record)-5-4, handshakeLen, + "handshake length header must match handshake body size") } -func TestRewriteSNI_SameLengthReplacement(t *testing.T) { +// TestBuildFakeClientHello_FitsOneSegment is the regression guard for the +// whole point of the rewrite: the fake must never need fragmenting on a +// standard 1500-byte path MTU. 1200 leaves ~260 bytes for IP+TCP headers and +// a generous safety margin — the X25519MLKEM768 ClientHello this replaces +// hit ~1400+. +func TestBuildFakeClientHello_FitsOneSegment(t *testing.T) { t.Parallel() - payload := decodeClientHello(t) - out, err := rewriteSNI(payload, "example.co") - require.NoError(t, err) - require.Len(t, out, len(payload)) - assertConsistent(t, out, "example.co") + for _, sni := range []string{"a.io", "example.com", strings.Repeat("a", 253)} { + record, err := buildFakeClientHello(sni) + require.NoError(t, err, "sni=%q", sni) + require.Less(t, len(record), 1200, "sni=%q built %d bytes", sni, len(record)) + } } -func TestRewriteSNI_LongerReplacement(t *testing.T) { +// TestBuildFakeClientHello_NoPostQuantumKeyShare catches regressions that +// would accidentally pull an X25519MLKEM768 key share (the reason the prior +// implementation had to fragment) back into the fake — e.g. if CurvePreferences +// stopped being respected by a future Go version. +func TestBuildFakeClientHello_NoPostQuantumKeyShare(t *testing.T) { t.Parallel() - payload := decodeClientHello(t) - out, err := rewriteSNI(payload, "letsencrypt.org") + record, err := buildFakeClientHello("example.com") require.NoError(t, err) - require.Len(t, out, len(payload)+5) // "letsencrypt.org" is 15, original 10, delta 5. - assertConsistent(t, out, "letsencrypt.org") + + var needle [2]byte + binary.BigEndian.PutUint16(needle[:], x25519MLKEM768) + require.False(t, bytes.Contains(record, needle[:]), + "output must not contain the X25519MLKEM768 code point (0x%04x)", x25519MLKEM768) } -func TestRewriteSNI_NoSNIReturnsError(t *testing.T) { +// TestBuildFakeClientHello_RandomizesPerCall ensures crypto/tls generates a +// fresh random + session_id + key_share on every call, as required to avoid +// trivial fingerprinting of the spoof. +func TestBuildFakeClientHello_RandomizesPerCall(t *testing.T) { t.Parallel() - // Truncated payload — not a valid ClientHello. - _, err := rewriteSNI([]byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01}, "x.com") + first, err := buildFakeClientHello("example.com") + require.NoError(t, err) + second, err := buildFakeClientHello("example.com") + require.NoError(t, err) + require.NotEqual(t, first, second, + "repeated calls must produce distinct bytes (random/session_id/key_share must vary)") +} + +func TestBuildFakeClientHello_RejectsEmpty(t *testing.T) { + t.Parallel() + _, err := buildFakeClientHello("") require.Error(t, err) } - -func TestRewriteSNI_DoesNotMutateInput(t *testing.T) { - t.Parallel() - payload := decodeClientHello(t) - original := append([]byte(nil), payload...) - _, err := rewriteSNI(payload, "letsencrypt.org") - require.NoError(t, err) - require.Equal(t, original, payload, "input payload must not be mutated") -} diff --git a/sing-box/common/tlsspoof/conn_test.go b/sing-box/common/tlsspoof/conn_test.go index 981f1a49c3..d27c53a989 100644 --- a/sing-box/common/tlsspoof/conn_test.go +++ b/sing-box/common/tlsspoof/conn_test.go @@ -1,19 +1,36 @@ package tlsspoof import ( + "bytes" + "context" + "encoding/binary" "encoding/hex" "io" "net" "testing" + "time" tf "github.com/sagernet/sing-box/common/tlsfragment" "github.com/stretchr/testify/require" ) +// realClientHello is a captured Chrome ClientHello for github.com. Tests that +// stack tlsspoof.Conn on top of tf.Conn still need a parseable payload to +// exercise the fragment transform. +const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100" + +func decodeClientHello(t *testing.T) []byte { + t.Helper() + payload, err := hex.DecodeString(realClientHello) + require.NoError(t, err) + return payload +} + type fakeSpoofer struct { injected [][]byte err error + closeErr error } func (f *fakeSpoofer) Inject(payload []byte) error { @@ -25,7 +42,7 @@ func (f *fakeSpoofer) Inject(payload []byte) error { } func (f *fakeSpoofer) Close() error { - return nil + return f.closeErr } func readAll(t *testing.T, conn net.Conn) []byte { @@ -37,12 +54,12 @@ func readAll(t *testing.T, conn net.Conn) []byte { func TestConn_Write_InjectsThenForwards(t *testing.T) { t.Parallel() - payload, err := hex.DecodeString(realClientHello) - require.NoError(t, err) + payload := decodeClientHello(t) client, server := net.Pipe() spoofer := &fakeSpoofer{} - wrapped := NewConn(client, spoofer, "letsencrypt.org") + wrapped, err := NewConn(client, spoofer, "letsencrypt.org") + require.NoError(t, err) serverRead := make(chan []byte, 1) go func() { @@ -66,12 +83,12 @@ func TestConn_Write_InjectsThenForwards(t *testing.T) { func TestConn_Write_SecondWriteDoesNotInject(t *testing.T) { t.Parallel() - payload, err := hex.DecodeString(realClientHello) - require.NoError(t, err) + payload := decodeClientHello(t) client, server := net.Pipe() spoofer := &fakeSpoofer{} - wrapped := NewConn(client, spoofer, "letsencrypt.org") + wrapped, err := NewConn(client, spoofer, "letsencrypt.org") + require.NoError(t, err) serverRead := make(chan []byte, 1) go func() { @@ -89,18 +106,244 @@ func TestConn_Write_SecondWriteDoesNotInject(t *testing.T) { require.Len(t, spoofer.injected, 1) } -func TestConn_Write_NonClientHelloReturnsError(t *testing.T) { +// TestConn_Write_SurfacesCloseError guards against the defer pattern silently +// dropping the spoofer's Close() error on the success path. +func TestConn_Write_SurfacesCloseError(t *testing.T) { + t.Parallel() + + client, server := net.Pipe() + defer client.Close() + defer server.Close() + spoofer := &fakeSpoofer{closeErr: errSpoofClose} + wrapped, err := NewConn(client, spoofer, "letsencrypt.org") + require.NoError(t, err) + + go func() { _, _ = io.ReadAll(server) }() + + _, err = wrapped.Write([]byte("trigger inject")) + require.ErrorIs(t, err, errSpoofClose, + "Close() error must be wrapped into Write's return") +} + +func TestConn_NewConn_RejectsEmptySNI(t *testing.T) { t.Parallel() client, server := net.Pipe() defer client.Close() defer server.Close() + _, err := NewConn(client, &fakeSpoofer{}, "") + require.Error(t, err, "empty SNI must fail at construction") +} +var errSpoofClose = errTest("spoof-close-failed") + +type errTest string + +func (e errTest) Error() string { return string(e) } + +// recordingConn intercepts each Write call so tests can assert how many +// downstream writes occurred and in what order with respect to spoof +// injection. It does not implement WithUpstream, so tf.Conn's +// N.UnwrapReader(conn).(*net.TCPConn) returns nil and fragment-mode falls +// back to its plain Write + time.Sleep path — which is what we want to +// exercise over a net.Pipe. +type recordingConn struct { + net.Conn + writes [][]byte + timeline *[]string +} + +func (c *recordingConn) Write(p []byte) (int, error) { + c.writes = append(c.writes, append([]byte(nil), p...)) + if c.timeline != nil { + *c.timeline = append(*c.timeline, "write") + } + return c.Conn.Write(p) +} + +type tlsRecord struct { + contentType byte + payload []byte +} + +func parseTLSRecords(t *testing.T, data []byte) []tlsRecord { + t.Helper() + var records []tlsRecord + for len(data) > 0 { + require.GreaterOrEqual(t, len(data), 5, "record header incomplete") + recordLen := int(binary.BigEndian.Uint16(data[3:5])) + require.GreaterOrEqual(t, len(data), 5+recordLen, "record payload truncated") + records = append(records, tlsRecord{ + contentType: data[0], + payload: append([]byte(nil), data[5:5+recordLen]...), + }) + data = data[5+recordLen:] + } + return records +} + +// TestConn_StackedWithRecordFragment mirrors the wrapping order that +// STDClientConfig.Client() produces when record_fragment is enabled: +// tls.Client → tlsspoof.Conn → tf.Conn → raw conn. +// Asserts the decoy is injected and the real handshake arrives split into +// multiple TLS records whose payloads reassemble to the original. +func TestConn_StackedWithRecordFragment(t *testing.T) { + t.Parallel() + payload := decodeClientHello(t) + + client, server := net.Pipe() + defer server.Close() + + fragConn := tf.NewConn(client, context.Background(), false, true, time.Millisecond) spoofer := &fakeSpoofer{} - wrapped := NewConn(client, spoofer, "letsencrypt.org") + wrapped, err := NewConn(fragConn, spoofer, "letsencrypt.org") + require.NoError(t, err) - _, err := wrapped.Write([]byte("not a ClientHello")) - require.Error(t, err) - require.Empty(t, spoofer.injected) + serverRead := make(chan []byte, 1) + go func() { serverRead <- readAll(t, server) }() + + _, err = wrapped.Write(payload) + require.NoError(t, err) + require.NoError(t, wrapped.Close()) + forwarded := <-serverRead + + require.Len(t, spoofer.injected, 1, "spoof must inject exactly once") + injected := tf.IndexTLSServerName(spoofer.injected[0]) + require.NotNil(t, injected, "injected payload must parse as ClientHello") + require.Equal(t, "letsencrypt.org", injected.ServerName) + + records := parseTLSRecords(t, forwarded) + require.Greater(t, len(records), 1, "record_fragment must produce multiple records") + var reassembled []byte + for _, r := range records { + require.Equal(t, byte(0x16), r.contentType, "all records must be handshake") + reassembled = append(reassembled, r.payload...) + } + require.Equal(t, payload[5:], reassembled, "record payloads must reassemble to original handshake") +} + +// TestConn_StackedWithPacketFragment is the primary regression test for the +// fragment-only gate fix in STDClientConfig.Client(). It verifies that +// packet-level fragmentation combined with spoof produces: +// - one spoof injection carrying the decoy SNI, +// - multiple separate writes to the underlying conn, +// - an unmodified byte stream when those writes are concatenated +// (no extra record framing). +func TestConn_StackedWithPacketFragment(t *testing.T) { + t.Parallel() + payload := decodeClientHello(t) + + client, server := net.Pipe() + defer server.Close() + + rc := &recordingConn{Conn: client} + fragConn := tf.NewConn(rc, context.Background(), true, false, time.Millisecond) + spoofer := &fakeSpoofer{} + wrapped, err := NewConn(fragConn, spoofer, "letsencrypt.org") + require.NoError(t, err) + + serverRead := make(chan []byte, 1) + go func() { serverRead <- readAll(t, server) }() + + _, err = wrapped.Write(payload) + require.NoError(t, err) + require.NoError(t, wrapped.Close()) + forwarded := <-serverRead + + require.Len(t, spoofer.injected, 1, "spoof must inject exactly once") + injected := tf.IndexTLSServerName(spoofer.injected[0]) + require.NotNil(t, injected) + require.Equal(t, "letsencrypt.org", injected.ServerName) + + require.Greater(t, len(rc.writes), 1, "fragment must split the ClientHello into multiple writes") + require.Equal(t, payload, bytes.Join(rc.writes, nil), + "concatenated writes must equal original bytes (no extra framing)") + require.Equal(t, payload, forwarded) +} + +// TestConn_StackedWithBothFragment exercises the combination that produces +// the strongest obfuscation: each chunk becomes its own TLS record and its +// own TCP write. +func TestConn_StackedWithBothFragment(t *testing.T) { + t.Parallel() + payload := decodeClientHello(t) + + client, server := net.Pipe() + defer server.Close() + + rc := &recordingConn{Conn: client} + fragConn := tf.NewConn(rc, context.Background(), true, true, time.Millisecond) + spoofer := &fakeSpoofer{} + wrapped, err := NewConn(fragConn, spoofer, "letsencrypt.org") + require.NoError(t, err) + + serverRead := make(chan []byte, 1) + go func() { serverRead <- readAll(t, server) }() + + _, err = wrapped.Write(payload) + require.NoError(t, err) + require.NoError(t, wrapped.Close()) + forwarded := <-serverRead + + require.Len(t, spoofer.injected, 1) + injected := tf.IndexTLSServerName(spoofer.injected[0]) + require.NotNil(t, injected) + require.Equal(t, "letsencrypt.org", injected.ServerName) + + require.Greater(t, len(rc.writes), 1, "split-packet must produce multiple writes") + records := parseTLSRecords(t, forwarded) + require.Greater(t, len(records), 1, "split-record must produce multiple records") + var reassembled []byte + for _, r := range records { + require.Equal(t, byte(0x16), r.contentType) + reassembled = append(reassembled, r.payload...) + } + require.Equal(t, payload[5:], reassembled, + "record payloads must reassemble to the original handshake") +} + +// trackingSpoofer adds the spoof injection to a shared event timeline so +// TestConn_StackedInjectionOrder can prove the decoy precedes the first +// downstream write. +type trackingSpoofer struct { + injected [][]byte + timeline *[]string +} + +func (s *trackingSpoofer) Inject(payload []byte) error { + s.injected = append(s.injected, append([]byte(nil), payload...)) + *s.timeline = append(*s.timeline, "inject") + return nil +} + +func (s *trackingSpoofer) Close() error { return nil } + +// TestConn_StackedInjectionOrder asserts the documented wire order: the +// decoy injection happens before any write reaches the underlying conn. +func TestConn_StackedInjectionOrder(t *testing.T) { + t.Parallel() + payload := decodeClientHello(t) + + client, server := net.Pipe() + defer server.Close() + + var timeline []string + rc := &recordingConn{Conn: client, timeline: &timeline} + fragConn := tf.NewConn(rc, context.Background(), true, true, time.Millisecond) + spoofer := &trackingSpoofer{timeline: &timeline} + wrapped, err := NewConn(fragConn, spoofer, "letsencrypt.org") + require.NoError(t, err) + + serverRead := make(chan []byte, 1) + go func() { serverRead <- readAll(t, server) }() + + _, err = wrapped.Write(payload) + require.NoError(t, err) + require.NoError(t, wrapped.Close()) + <-serverRead + + require.NotEmpty(t, timeline) + require.Equal(t, "inject", timeline[0], "decoy must be injected before any downstream write") + require.Contains(t, timeline[1:], "write", "at least one downstream write must follow the inject") } func TestParseMethod(t *testing.T) { diff --git a/sing-box/common/tlsspoof/integration_test.go b/sing-box/common/tlsspoof/integration_test.go index e365929089..23a83ff174 100644 --- a/sing-box/common/tlsspoof/integration_test.go +++ b/sing-box/common/tlsspoof/integration_test.go @@ -11,7 +11,7 @@ import ( "os" "os/exec" "strings" - "sync/atomic" + "sync" "testing" "time" @@ -21,11 +21,20 @@ import ( func requireRoot(t *testing.T) { t.Helper() if os.Geteuid() != 0 { - t.Fatal("integration test requires root") + t.Skip("integration test requires root; re-run with `go test -exec sudo`") } } func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool { + t.Helper() + return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle] +} + +// tcpdumpObserverMulti captures tcpdump output while do() executes and reports +// which of the provided needles were observed in the raw ASCII dump. Use this +// to assert that distinct payloads (e.g. fake vs real ClientHello) are both on +// the wire. +func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), wait) defer cancel() @@ -62,16 +71,22 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do t.Fatal("tcpdump did not attach within 2s") } - var found atomic.Bool + var access sync.Mutex + found := make(map[string]bool, len(needles)) readerDone := make(chan struct{}) go func() { defer close(readerDone) scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { - if strings.Contains(scanner.Text(), needle) { - found.Store(true) + line := scanner.Text() + access.Lock() + for _, needle := range needles { + if !found[needle] && strings.Contains(line, needle) { + found[needle] = true + } } + access.Unlock() } }() @@ -80,12 +95,26 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do time.Sleep(200 * time.Millisecond) _ = cmd.Process.Signal(os.Interrupt) <-readerDone - return found.Load() + access.Lock() + defer access.Unlock() + result := make(map[string]bool, len(needles)) + for _, needle := range needles { + result[needle] = found[needle] + } + return result } func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) { + return dialLocalEchoServerFamily(t, "tcp4", "127.0.0.1:0") +} + +func dialLocalEchoServerIPv6(t *testing.T) (client net.Conn, serverPort uint16) { + return dialLocalEchoServerFamily(t, "tcp6", "[::1]:0") +} + +func dialLocalEchoServerFamily(t *testing.T, network, address string) (client net.Conn, serverPort uint16) { t.Helper() - listener, err := net.Listen("tcp4", "127.0.0.1:0") + listener, err := net.Listen(network, address) require.NoError(t, err) accepted := make(chan net.Conn, 1) @@ -97,7 +126,7 @@ func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) { close(accepted) }() addr := listener.Addr().(*net.TCPAddr) - client, err = net.Dial("tcp4", addr.String()) + client, err = net.Dial(network, addr.String()) require.NoError(t, err) server := <-accepted require.NotNil(t, server) diff --git a/sing-box/common/tlsspoof/integration_tls_test.go b/sing-box/common/tlsspoof/integration_tls_test.go new file mode 100644 index 0000000000..669df7eb2f --- /dev/null +++ b/sing-box/common/tlsspoof/integration_tls_test.go @@ -0,0 +1,120 @@ +//go:build linux || darwin + +package tlsspoof + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// generateSelfSignedCert returns a TLS certificate valid for the given SAN. +func generateSelfSignedCert(t *testing.T, commonName string, sans ...string) tls.Certificate { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + require.NoError(t, err) + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: sans, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + require.NoError(t, err) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalPKCS8PrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + return cert +} + +// TestIntegrationConn_RealTLSHandshake drives a real crypto/tls ClientHello +// through the spoofer and asserts the on-wire fake packet carries the fake SNI +// while the server receives the real SNI. This exercises the full +// `tls.Client(wrapped, config).Handshake()` path rather than a static hex +// payload, matching what user-facing code hits. +func TestIntegrationConn_RealTLSHandshake(t *testing.T) { + requireRoot(t) + const realSNI = "real.test" + const fakeSNI = "fake.test" + + serverCert := generateSelfSignedCert(t, realSNI, realSNI) + tlsConfig := &tls.Config{Certificates: []tls.Certificate{serverCert}} + + listener, err := tls.Listen("tcp4", "127.0.0.1:0", tlsConfig) + require.NoError(t, err) + t.Cleanup(func() { listener.Close() }) + + serverSNI := make(chan string, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + tlsConn := conn.(*tls.Conn) + _ = tlsConn.SetDeadline(time.Now().Add(3 * time.Second)) + if handshakeErr := tlsConn.Handshake(); handshakeErr != nil { + serverSNI <- "handshake-error:" + handshakeErr.Error() + return + } + serverSNI <- tlsConn.ConnectionState().ServerName + _, _ = io.Copy(io.Discard, conn) + }() + + addr := listener.Addr().(*net.TCPAddr) + serverPort := uint16(addr.Port) + raw, err := net.Dial("tcp4", addr.String()) + require.NoError(t, err) + t.Cleanup(func() { raw.Close() }) + + spoofer, err := NewSpoofer(raw, MethodWrongSequence) + require.NoError(t, err) + wrapped, err := NewConn(raw, spoofer, fakeSNI) + require.NoError(t, err) + + clientConfig := &tls.Config{ + ServerName: realSNI, + InsecureSkipVerify: true, + } + tlsClient := tls.Client(wrapped, clientConfig) + t.Cleanup(func() { tlsClient.Close() }) + + seen := tcpdumpObserverMulti(t, loopbackInterface, serverPort, + []string{realSNI, fakeSNI}, func() { + _ = tlsClient.SetDeadline(time.Now().Add(3 * time.Second)) + err := tlsClient.Handshake() + require.NoError(t, err, "TLS handshake must succeed (wrong-sequence fake is dropped by peer)") + }, 4*time.Second) + + require.True(t, seen[realSNI], + "real ClientHello on the wire must contain original SNI %q", realSNI) + require.True(t, seen[fakeSNI], + "fake ClientHello on the wire must contain fake SNI %q", fakeSNI) + + select { + case sniOnServer := <-serverSNI: + require.Equal(t, realSNI, sniOnServer, + "TLS server must see the real SNI (fake packet dropped by peer TCP stack)") + case <-time.After(3 * time.Second): + t.Fatal("TLS server did not complete handshake") + } +} diff --git a/sing-box/common/tlsspoof/integration_unix_test.go b/sing-box/common/tlsspoof/integration_unix_test.go index c734ed891a..c5d26128a5 100644 --- a/sing-box/common/tlsspoof/integration_unix_test.go +++ b/sing-box/common/tlsspoof/integration_unix_test.go @@ -19,9 +19,7 @@ func TestIntegrationSpoofer_WrongChecksum(t *testing.T) { require.NoError(t, err) defer spoofer.Close() - payload, err := hex.DecodeString(realClientHello) - require.NoError(t, err) - fake, err := rewriteSNI(payload, "letsencrypt.org") + fake, err := buildFakeClientHello("letsencrypt.org") require.NoError(t, err) captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() { @@ -37,9 +35,39 @@ func TestIntegrationSpoofer_WrongSequence(t *testing.T) { require.NoError(t, err) defer spoofer.Close() - payload, err := hex.DecodeString(realClientHello) + fake, err := buildFakeClientHello("letsencrypt.org") require.NoError(t, err) - fake, err := rewriteSNI(payload, "letsencrypt.org") + + captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() { + require.NoError(t, spoofer.Inject(fake)) + }, 3*time.Second) + require.True(t, captured, "injected fake ClientHello must be observable on loopback") +} + +func TestIntegrationSpoofer_IPv6_WrongChecksum(t *testing.T) { + requireRoot(t) + client, serverPort := dialLocalEchoServerIPv6(t) + spoofer, err := NewSpoofer(client, MethodWrongChecksum) + require.NoError(t, err) + defer spoofer.Close() + + fake, err := buildFakeClientHello("letsencrypt.org") + require.NoError(t, err) + + captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() { + require.NoError(t, spoofer.Inject(fake)) + }, 3*time.Second) + require.True(t, captured, "injected fake ClientHello must be observable on loopback") +} + +func TestIntegrationSpoofer_IPv6_WrongSequence(t *testing.T) { + requireRoot(t) + client, serverPort := dialLocalEchoServerIPv6(t) + spoofer, err := NewSpoofer(client, MethodWrongSequence) + require.NoError(t, err) + defer spoofer.Close() + + fake, err := buildFakeClientHello("letsencrypt.org") require.NoError(t, err) captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() { @@ -51,8 +79,34 @@ func TestIntegrationSpoofer_WrongSequence(t *testing.T) { // Loopback bypasses TCP checksum validation, so wrong-sequence is used instead. func TestIntegrationConn_InjectsThenForwardsRealCH(t *testing.T) { requireRoot(t) + runInjectsThenForwardsRealCH(t, "tcp4", "127.0.0.1:0") +} - listener, err := net.Listen("tcp4", "127.0.0.1:0") +func TestIntegrationConn_IPv6_InjectsThenForwardsRealCH(t *testing.T) { + requireRoot(t) + runInjectsThenForwardsRealCH(t, "tcp6", "[::1]:0") +} + +// TestIntegrationConn_FakeAndRealHaveDistinctSNIs asserts that the on-wire fake +// packet carries the fake SNI (letsencrypt.org) AND the real packet still +// carries the original SNI (github.com). If the builder regresses to producing +// empty or mismatched bytes, the fake-SNI needle will be missing. +func TestIntegrationConn_FakeAndRealHaveDistinctSNIs(t *testing.T) { + requireRoot(t) + runFakeAndRealHaveDistinctSNIs(t, "tcp4", "127.0.0.1:0", "letsencrypt.org") +} + +func TestIntegrationConn_IPv6_FakeAndRealHaveDistinctSNIs(t *testing.T) { + requireRoot(t) + runFakeAndRealHaveDistinctSNIs(t, "tcp6", "[::1]:0", "letsencrypt.org") +} + +func runFakeAndRealHaveDistinctSNIs(t *testing.T, network, address, fakeSNI string) { + t.Helper() + const originalSNI = "github.com" + require.NotEqual(t, originalSNI, fakeSNI) + + listener, err := net.Listen(network, address) require.NoError(t, err) serverReceived := make(chan []byte, 1) @@ -69,7 +123,7 @@ func TestIntegrationConn_InjectsThenForwardsRealCH(t *testing.T) { addr := listener.Addr().(*net.TCPAddr) serverPort := uint16(addr.Port) - client, err := net.Dial("tcp4", addr.String()) + client, err := net.Dial(network, addr.String()) require.NoError(t, err) t.Cleanup(func() { client.Close() @@ -78,7 +132,63 @@ func TestIntegrationConn_InjectsThenForwardsRealCH(t *testing.T) { spoofer, err := NewSpoofer(client, MethodWrongSequence) require.NoError(t, err) - wrapped := NewConn(client, spoofer, "letsencrypt.org") + wrapped, err := NewConn(client, spoofer, fakeSNI) + require.NoError(t, err) + + payload, err := hex.DecodeString(realClientHello) + require.NoError(t, err) + + seen := tcpdumpObserverMulti(t, loopbackInterface, serverPort, + []string{originalSNI, fakeSNI}, func() { + n, err := wrapped.Write(payload) + require.NoError(t, err) + require.Equal(t, len(payload), n) + }, 3*time.Second) + require.True(t, seen[originalSNI], + "real ClientHello must carry original SNI %q on the wire", originalSNI) + require.True(t, seen[fakeSNI], + "fake ClientHello must carry fake SNI %q on the wire", fakeSNI) + + _ = wrapped.Close() + select { + case got := <-serverReceived: + require.Equal(t, payload, got, + "server must receive real ClientHello unchanged (wrong-sequence fake must be dropped)") + case <-time.After(2 * time.Second): + t.Fatal("echo server did not receive real ClientHello") + } +} + +func runInjectsThenForwardsRealCH(t *testing.T, network, address string) { + t.Helper() + listener, err := net.Listen(network, address) + require.NoError(t, err) + + serverReceived := make(chan []byte, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + got, _ := io.ReadAll(conn) + serverReceived <- got + }() + + addr := listener.Addr().(*net.TCPAddr) + serverPort := uint16(addr.Port) + client, err := net.Dial(network, addr.String()) + require.NoError(t, err) + t.Cleanup(func() { + client.Close() + listener.Close() + }) + + spoofer, err := NewSpoofer(client, MethodWrongSequence) + require.NoError(t, err) + wrapped, err := NewConn(client, spoofer, "letsencrypt.org") + require.NoError(t, err) payload, err := hex.DecodeString(realClientHello) require.NoError(t, err) diff --git a/sing-box/common/tlsspoof/integration_windows_test.go b/sing-box/common/tlsspoof/integration_windows_test.go index d3f823841e..3b8e3c549e 100644 --- a/sing-box/common/tlsspoof/integration_windows_test.go +++ b/sing-box/common/tlsspoof/integration_windows_test.go @@ -46,11 +46,10 @@ func TestIntegrationSpooferOpenClose(t *testing.T) { require.NoError(t, spoofer.Close()) } -// End-to-end: Conn.Write injects a fake ClientHello with a rewritten -// SNI, then forwards the real ClientHello. With wrong-sequence, the -// fake lands before the connection's send-next sequence — the peer TCP -// stack treats it as already-received and only surfaces the real bytes -// to the echo server. +// End-to-end: Conn.Write injects a fake ClientHello with a fresh SNI, then +// forwards the real ClientHello. With wrong-sequence, the fake lands before +// the connection's send-next sequence — the peer TCP stack treats it as +// already-received and only surfaces the real bytes to the echo server. func TestIntegrationConnInjectsThenForwardsRealCH(t *testing.T) { listener, err := net.Listen("tcp4", "127.0.0.1:0") require.NoError(t, err) @@ -73,7 +72,8 @@ func TestIntegrationConnInjectsThenForwardsRealCH(t *testing.T) { t.Cleanup(func() { client.Close() }) spoofer := newSpoofer(t, client, MethodWrongSequence) - wrapped := NewConn(client, spoofer, "letsencrypt.org") + wrapped, err := NewConn(client, spoofer, "letsencrypt.org") + require.NoError(t, err) payload, err := hex.DecodeString(realClientHello) require.NoError(t, err) @@ -119,12 +119,12 @@ func TestIntegrationSpooferInjectThenWrite(t *testing.T) { spoofer := newSpoofer(t, client, MethodWrongSequence) t.Cleanup(func() { spoofer.Close() }) - payload, err := hex.DecodeString(realClientHello) - require.NoError(t, err) - fake, err := rewriteSNI(payload, "letsencrypt.org") + fake, err := buildFakeClientHello("letsencrypt.org") require.NoError(t, err) require.NoError(t, spoofer.Inject(fake)) + payload, err := hex.DecodeString(realClientHello) + require.NoError(t, err) n, err := client.Write(payload) require.NoError(t, err) require.Equal(t, len(payload), n) diff --git a/sing-box/common/tlsspoof/packet.go b/sing-box/common/tlsspoof/packet.go index d84fc4b12c..9bdf7a59d9 100644 --- a/sing-box/common/tlsspoof/packet.go +++ b/sing-box/common/tlsspoof/packet.go @@ -74,20 +74,36 @@ func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, seqNum, a } func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) { - var sequence uint32 - corrupt := false - switch method { - case MethodWrongSequence: - sequence = sendNext - uint32(len(payload)) - case MethodWrongChecksum: - sequence = sendNext - corrupt = true - default: - return nil, E.New("tls_spoof: unknown method ", method) + sequence, corrupt, err := resolveSpoofSequence(method, sendNext, payload) + if err != nil { + return nil, err } return buildTCPSegment(src, dst, sequence, receiveNext, payload, corrupt), nil } +// buildSpoofTCPSegment returns a TCP segment without an IP header, for +// platforms where the kernel synthesises the IP header (darwin IPv6). +func buildSpoofTCPSegment(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) { + sequence, corrupt, err := resolveSpoofSequence(method, sendNext, payload) + if err != nil { + return nil, err + } + segment := make([]byte, tcpHeaderLen+len(payload)) + encodeTCP(segment, 0, src, dst, sequence, receiveNext, payload, corrupt) + return segment, nil +} + +func resolveSpoofSequence(method Method, sendNext uint32, payload []byte) (uint32, bool, error) { + switch method { + case MethodWrongSequence: + return sendNext - uint32(len(payload)), false, nil + case MethodWrongChecksum: + return sendNext, true, nil + default: + return 0, false, E.New("tls_spoof: unknown method ", method) + } +} + func applyTCPChecksum(tcp header.TCP, srcAddr, dstAddr netip.Addr, payload []byte, corrupt bool) { tcpLen := tcpHeaderLen + len(payload) pseudo := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcAddr.AsSlice(), dstAddr.AsSlice(), uint16(tcpLen)) diff --git a/sing-box/common/tlsspoof/packet_test.go b/sing-box/common/tlsspoof/packet_test.go index 992a96840e..5c6d5b6be4 100644 --- a/sing-box/common/tlsspoof/packet_test.go +++ b/sing-box/common/tlsspoof/packet_test.go @@ -75,3 +75,62 @@ func TestBuildTCPSegment_MixedFamilyPanics(t *testing.T) { buildTCPSegment(src, dst, 0, 0, nil, false) }) } + +func TestBuildSpoofFrame_WrongSequence(t *testing.T) { + t.Parallel() + src := netip.MustParseAddrPort("10.0.0.1:54321") + dst := netip.MustParseAddrPort("1.2.3.4:443") + payload := []byte("fake-client-hello") + const sendNext uint32 = 10_000 + frame, err := buildSpoofFrame(MethodWrongSequence, src, dst, sendNext, 20_000, payload) + require.NoError(t, err) + + tcp := header.TCP(frame[header.IPv4MinimumSize:]) + require.Equal(t, sendNext-uint32(len(payload)), tcp.SequenceNumber(), + "wrong-sequence places the fake at sendNext-len(payload)") + require.True(t, tcp.Flags().Contains(header.TCPFlagAck|header.TCPFlagPsh)) + + // Checksum must still be valid — only the sequence number is wrong. + payloadChecksum := checksum.Checksum(payload, 0) + require.True(t, tcp.IsChecksumValid( + tcpip.AddrFrom4(src.Addr().As4()), + tcpip.AddrFrom4(dst.Addr().As4()), + payloadChecksum, + uint16(len(payload)), + )) +} + +func TestBuildSpoofFrame_WrongChecksum(t *testing.T) { + t.Parallel() + src := netip.MustParseAddrPort("10.0.0.1:54321") + dst := netip.MustParseAddrPort("1.2.3.4:443") + payload := []byte("fake-client-hello") + const sendNext uint32 = 5_000 + frame, err := buildSpoofFrame(MethodWrongChecksum, src, dst, sendNext, 20_000, payload) + require.NoError(t, err) + + tcp := header.TCP(frame[header.IPv4MinimumSize:]) + require.Equal(t, sendNext, tcp.SequenceNumber(), + "wrong-checksum keeps the real sequence number") + + payloadChecksum := checksum.Checksum(payload, 0) + require.False(t, tcp.IsChecksumValid( + tcpip.AddrFrom4(src.Addr().As4()), + tcpip.AddrFrom4(dst.Addr().As4()), + payloadChecksum, + uint16(len(payload)), + )) + require.True(t, header.IPv4(frame[:header.IPv4MinimumSize]).IsChecksumValid(), + "IPv4 checksum must remain valid so the router forwards the packet") +} + +func TestBuildSpoofTCPSegment_EncodesWithoutIPHeader(t *testing.T) { + t.Parallel() + src := netip.MustParseAddrPort("[fe80::1]:54321") + dst := netip.MustParseAddrPort("[2606:4700::1]:443") + payload := []byte("fake-client-hello") + segment, err := buildSpoofTCPSegment(MethodWrongSequence, src, dst, 1000, 2000, payload) + require.NoError(t, err) + require.Equal(t, tcpHeaderLen+len(payload), len(segment), + "segment must be TCP header + payload, no IP header") +} diff --git a/sing-box/common/tlsspoof/raw_darwin.go b/sing-box/common/tlsspoof/raw_darwin.go index 170561a872..73b11dd37a 100644 --- a/sing-box/common/tlsspoof/raw_darwin.go +++ b/sing-box/common/tlsspoof/raw_darwin.go @@ -9,6 +9,7 @@ import ( "sync" "syscall" + "github.com/sagernet/sing-tun/gtcpip/header" E "github.com/sagernet/sing/common/exceptions" "golang.org/x/sys/unix" @@ -34,14 +35,26 @@ const ( darwinXtcpcbRcvNxtOffset = 80 ) -var darwinStructSize = sync.OnceValue(func() int { - value, _ := syscall.Sysctl("kern.osrelease") - major, _, _ := strings.Cut(value, ".") - n, _ := strconv.ParseInt(major, 10, 64) - if n >= 22 { - return 408 +// darwinStructSize returns the size of xinpcb_n for the running Darwin kernel. +// Darwin 22 (macOS 13 Ventura) grew the struct from 384 to 408 bytes; there is +// no ABI-stable way to read it, so we key off the kernel version. +var darwinStructSize = sync.OnceValues(func() (int, error) { + value, err := syscall.Sysctl("kern.osrelease") + if err != nil { + return 0, E.Cause(err, "sysctl kern.osrelease") } - return 384 + major, _, ok := strings.Cut(value, ".") + if !ok { + return 0, E.New("unexpected kern.osrelease format: ", value) + } + n, err := strconv.ParseInt(major, 10, 64) + if err != nil { + return 0, E.Cause(err, "parse kern.osrelease major version: ", value) + } + if n >= 22 { + return 408, nil + } + return 384, nil }) type darwinSpoofer struct { @@ -59,7 +72,7 @@ func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) { if err != nil { return nil, err } - fd, sockaddr, err := openDarwinRawSocket(dst) + fd, sockaddr, err := openDarwinRawSocket(src, dst) if err != nil { return nil, err } @@ -87,7 +100,10 @@ func readDarwinTCPSequence(src, dst netip.AddrPort) (uint32, uint32, error) { if err != nil { return 0, 0, E.Cause(err, "sysctl net.inet.tcp.pcblist_n") } - structSize := darwinStructSize() + structSize, err := darwinStructSize() + if err != nil { + return 0, 0, err + } itemSize := structSize + darwinTCPExtraSize for i := darwinXinpgenSize; i+itemSize <= len(buffer); i += itemSize { inpcb := buffer[i : i+darwinXsocketOffset] @@ -119,31 +135,50 @@ func readDarwinTCPSequence(src, dst netip.AddrPort) (uint32, uint32, error) { return 0, 0, E.New("tls_spoof: connection ", src, "->", dst, " not found in pcblist_n") } -func openDarwinRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) { - if !dst.Addr().Is4() { - // macOS does not expose IPV6_HDRINCL; raw AF_INET6 injection would - // require either BPF link-layer writes or kernel-side IPv6 header - // synthesis, neither of which is implemented here. - return -1, nil, E.New("tls_spoof: IPv6 not supported on darwin") +func openDarwinRawSocket(src, dst netip.AddrPort) (int, unix.Sockaddr, error) { + if dst.Addr().Is4() { + return openIPv4RawSocket(dst) } - return openIPv4RawSocket(dst) + // macOS does not accept IPV6_HDRINCL on AF_INET6 SOCK_RAW IPPROTO_TCP + // sockets, so the kernel builds the IPv6 header itself. Bind to the real + // connection's source address so in6_selectsrc returns it, and rely on + // in6p_cksum defaulting to -1 so the user-supplied TCP checksum is + // preserved (including deliberately corrupted ones). + fd, err := unix.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_TCP) + if err != nil { + return -1, nil, E.Cause(err, "open AF_INET6 SOCK_RAW") + } + err = unix.Bind(fd, &unix.SockaddrInet6{Addr: src.Addr().As16()}) + if err != nil { + unix.Close(fd) + return -1, nil, E.Cause(err, "bind AF_INET6 SOCK_RAW") + } + sockaddr := &unix.SockaddrInet6{Port: int(dst.Port()), Addr: dst.Addr().As16()} + return fd, sockaddr, nil } func (s *darwinSpoofer) Inject(payload []byte) error { + if !s.src.Addr().Is4() { + segment, err := buildSpoofTCPSegment(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload) + if err != nil { + return err + } + err = unix.Sendto(s.rawFD, segment, 0, s.rawSockAddr) + if err != nil { + return E.Cause(err, "sendto raw socket") + } + return nil + } frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload) if err != nil { return err } // Darwin inherits the historical BSD quirk: with IP_HDRINCL the kernel // expects ip_len and ip_off in host byte order, not network byte order. - // Apple's rip_output swaps them back before transmission. This does not - // apply to IPv6. - if s.src.Addr().Is4() { - totalLen := binary.BigEndian.Uint16(frame[2:4]) - binary.NativeEndian.PutUint16(frame[2:4], totalLen) - fragOff := binary.BigEndian.Uint16(frame[6:8]) - binary.NativeEndian.PutUint16(frame[6:8], fragOff) - } + // Apple's rip_output swaps them back before transmission. + ip := header.IPv4(frame) + ip.SetTotalLengthDarwinRaw(ip.TotalLength()) + ip.SetFlagsFragmentOffsetDarwinRaw(ip.Flags(), ip.FragmentOffset()) err = unix.Sendto(s.rawFD, frame, 0, s.rawSockAddr) if err != nil { return E.Cause(err, "sendto raw socket") diff --git a/sing-box/common/tlsspoof/raw_linux.go b/sing-box/common/tlsspoof/raw_linux.go index cb694aba96..70774c297c 100644 --- a/sing-box/common/tlsspoof/raw_linux.go +++ b/sing-box/common/tlsspoof/raw_linux.go @@ -66,22 +66,34 @@ func openLinuxRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) { unix.Close(fd) return -1, nil, E.Cause(err, "set IPV6_HDRINCL") } - sockaddr := &unix.SockaddrInet6{Port: int(dst.Port())} - sockaddr.Addr = dst.Addr().As16() + // Linux raw IPv6 sockets interpret sin6_port as a nexthdr protocol number + // (see raw(7)); any value other than 0 or the socket's IPPROTO_TCP causes + // sendto to fail with EINVAL. The destination is already encoded in the + // user-supplied IPv6 header under IPV6_HDRINCL. + sockaddr := &unix.SockaddrInet6{Addr: dst.Addr().As16()} return fd, sockaddr, nil } // loadSequenceNumbers puts the socket briefly into TCP_REPAIR mode to read // snd_nxt and rcv_nxt from the kernel. TCP_REPAIR requires CAP_NET_ADMIN; // callers must run as root or grant both CAP_NET_RAW and CAP_NET_ADMIN. +// +// If the TCP_REPAIR_OFF revert fails, the socket would stay in TCP_REPAIR +// state and subsequent Write() calls would silently buffer instead of sending. +// Surface that error so callers can abort. func (s *linuxSpoofer) loadSequenceNumbers(tcpConn *net.TCPConn) error { - return control.Conn(tcpConn, func(raw uintptr) error { + return control.Conn(tcpConn, func(raw uintptr) (err error) { fd := int(raw) - err := unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_ON) + err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_ON) if err != nil { return E.Cause(err, "enter TCP_REPAIR (need CAP_NET_ADMIN)") } - defer unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_OFF) + defer func() { + offErr := unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_OFF) + if err == nil && offErr != nil { + err = E.Cause(offErr, "leave TCP_REPAIR") + } + }() err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR_QUEUE, tcpSendQueue) if err != nil { diff --git a/sing-box/common/tlsspoof/raw_windows.go b/sing-box/common/tlsspoof/raw_windows.go index b6961169f1..4c6d130fbf 100644 --- a/sing-box/common/tlsspoof/raw_windows.go +++ b/sing-box/common/tlsspoof/raw_windows.go @@ -25,11 +25,15 @@ const PlatformSupported = true // bounds the pathological case where the kernel buffers the packet. const closeGracePeriod = 2 * time.Second +// windowsSpoofer uses a single WinDivert handle for both capture and +// injection. Sequential Send() calls on one handle traverse one driver queue, +// so the fake provably precedes the released real on the wire — a guarantee +// two separate handles cannot make because cross-handle order depends on the +// scheduler. type windowsSpoofer struct { method Method src, dst netip.AddrPort divertH *windivert.Handle - injectH *windivert.Handle fakeReady chan []byte // buffered(1): staged by Inject done chan struct{} // closed by run() on exit @@ -42,7 +46,6 @@ func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) { if err != nil { return nil, err } - filter, err := windivert.OutboundTCP(src, dst) if err != nil { return nil, err @@ -51,17 +54,11 @@ func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) { if err != nil { return nil, E.Cause(err, "tls_spoof: open WinDivert") } - injectH, err := windivert.Open(nil, windivert.LayerNetwork, 0, windivert.FlagSendOnly) - if err != nil { - divertH.Close() - return nil, E.Cause(err, "tls_spoof: open WinDivert") - } s := &windowsSpoofer{ method: method, src: src, dst: dst, divertH: divertH, - injectH: injectH, fakeReady: make(chan []byte, 1), done: make(chan struct{}), } @@ -91,7 +88,6 @@ func (s *windowsSpoofer) Close() error { s.divertH.Close() <-s.done } - s.injectH.Close() }) if p := s.runErr.Load(); p != nil { return *p @@ -119,9 +115,17 @@ func (s *windowsSpoofer) run() { pkt := buf[:n] seq, ack, payloadLen, ok := parseTCPFields(pkt, addr.IPv6()) if !ok { - // Malformed / not TCP — shouldn't match our filter, but be safe. - _, _ = s.divertH.Send(pkt, &addr) - continue + // Our filter is OutboundTCP(src, dst); a non-TCP or truncated + // match means driver state is suspect. Re-inject so the kernel + // still sees the byte stream, then abort — continuing would risk + // reordering against an unknown reference point. + _, sendErr := s.divertH.Send(pkt, &addr) + if sendErr != nil { + s.recordErr(E.Cause(sendErr, "windivert re-inject malformed")) + return + } + s.recordErr(E.New("windivert received malformed packet matching spoof filter")) + return } if payloadLen == 0 { // Handshake ACK, keepalive, FIN — pass through unchanged. @@ -159,7 +163,7 @@ func (s *windowsSpoofer) run() { // Force both to 1 to keep our bytes intact. fakeAddr.SetIPChecksum(true) fakeAddr.SetTCPChecksum(true) - _, err = s.injectH.Send(frame, &fakeAddr) + _, err = s.divertH.Send(frame, &fakeAddr) if err != nil { s.recordErr(E.Cause(err, "windivert inject fake")) return diff --git a/sing-box/common/tlsspoof/spoof.go b/sing-box/common/tlsspoof/spoof.go index 2a27ec3280..249af08f6c 100644 --- a/sing-box/common/tlsspoof/spoof.go +++ b/sing-box/common/tlsspoof/spoof.go @@ -51,29 +51,34 @@ func NewSpoofer(conn net.Conn, method Method) (Spoofer, error) { type Conn struct { net.Conn - spoofer Spoofer - fakeSNI string - injected bool + spoofer Spoofer + fakeHello []byte + injected bool } -func NewConn(conn net.Conn, spoofer Spoofer, fakeSNI string) *Conn { - return &Conn{ - Conn: conn, - spoofer: spoofer, - fakeSNI: fakeSNI, +func NewConn(conn net.Conn, spoofer Spoofer, fakeSNI string) (*Conn, error) { + fakeHello, err := buildFakeClientHello(fakeSNI) + if err != nil { + return nil, E.Cause(err, "tls_spoof: build fake ClientHello") } + return &Conn{ + Conn: conn, + spoofer: spoofer, + fakeHello: fakeHello, + }, nil } -func (c *Conn) Write(b []byte) (int, error) { +func (c *Conn) Write(b []byte) (n int, err error) { if c.injected { return c.Conn.Write(b) } - defer c.spoofer.Close() - fake, err := rewriteSNI(b, c.fakeSNI) - if err != nil { - return 0, E.Cause(err, "tls_spoof: rewrite SNI") - } - err = c.spoofer.Inject(fake) + defer func() { + closeErr := c.spoofer.Close() + if err == nil && closeErr != nil { + err = E.Cause(closeErr, "tls_spoof: close spoofer") + } + }() + err = c.spoofer.Inject(c.fakeHello) if err != nil { return 0, E.Cause(err, "tls_spoof: inject") } diff --git a/sing-box/common/windivert/handle_windows.go b/sing-box/common/windivert/handle_windows.go index e7f5ae6736..1d7aebfdef 100644 --- a/sing-box/common/windivert/handle_windows.go +++ b/sing-box/common/windivert/handle_windows.go @@ -110,9 +110,13 @@ func validateOpenArgs(layer Layer, priority int16, flags Flag) error { if priority < PriorityLowest || priority > PriorityHighest { return E.New("windivert: priority out of range") } - if flags&^FlagSendOnly != 0 { + const supportedFlags = FlagSniff | FlagSendOnly + if flags&^supportedFlags != 0 { return E.New("windivert: unknown flag bits") } + if flags&FlagSniff != 0 && flags&FlagSendOnly != 0 { + return E.New("windivert: FlagSniff and FlagSendOnly are mutually exclusive") + } return nil } diff --git a/sing-box/common/windivert/handle_windows_test.go b/sing-box/common/windivert/handle_windows_test.go index dd05ce7b0c..73dfbb166a 100644 --- a/sing-box/common/windivert/handle_windows_test.go +++ b/sing-box/common/windivert/handle_windows_test.go @@ -100,6 +100,9 @@ func TestValidateOpenArgsFlags(t *testing.T) { t.Parallel() require.NoError(t, validateOpenArgs(LayerNetwork, 0, 0)) require.NoError(t, validateOpenArgs(LayerNetwork, 0, FlagSendOnly)) + require.NoError(t, validateOpenArgs(LayerNetwork, 0, FlagSniff)) + // Sniff and send-only describe contradictory handle roles. + require.Error(t, validateOpenArgs(LayerNetwork, 0, FlagSniff|FlagSendOnly)) // Unknown flag bits must be rejected to surface caller mistakes early. require.Error(t, validateOpenArgs(LayerNetwork, 0, Flag(0x10))) require.Error(t, validateOpenArgs(LayerNetwork, 0, FlagSendOnly|Flag(0x10))) diff --git a/sing-box/common/windivert/windivert.go b/sing-box/common/windivert/windivert.go index e9a8fc9545..9d309886cb 100644 --- a/sing-box/common/windivert/windivert.go +++ b/sing-box/common/windivert/windivert.go @@ -23,7 +23,14 @@ const LayerNetwork Layer = 0 type Flag uint64 -const FlagSendOnly Flag = 0x0008 +const ( + // FlagSniff opens a passive observer: the driver copies matching packets + // to userspace without removing them from the network stack. Send is not + // required (and not allowed) on a sniffing handle. + FlagSniff Flag = 0x0001 + // FlagSendOnly opens a write-only injection handle; Recv is not allowed. + FlagSendOnly Flag = 0x0008 +) const ( PriorityHighest int16 = 30000 diff --git a/sing-box/dns/client.go b/sing-box/dns/client.go index 37ba98a84f..53ab4ccd62 100644 --- a/sing-box/dns/client.go +++ b/sing-box/dns/client.go @@ -536,11 +536,24 @@ func (c *Client) prepareExchangeMessage(message *dns.Msg, options adapter.DNSQue return message } +func stripDNSPadding(response *dns.Msg) { + for _, record := range response.Extra { + opt, isOpt := record.(*dns.OPT) + if !isOpt { + continue + } + opt.Option = common.Filter(opt.Option, func(it dns.EDNS0) bool { + return it.Option() != dns.EDNS0PADDING + }) + } +} + func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg) (*dns.Msg, error) { ctx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() response, err := transport.Exchange(ctx, message) if err == nil { + stripDNSPadding(response) return response, nil } var rcodeError RcodeError diff --git a/sing-box/docs/changelog.md b/sing-box/docs/changelog.md index 407f66531f..590d7bf1c8 100644 --- a/sing-box/docs/changelog.md +++ b/sing-box/docs/changelog.md @@ -2,6 +2,81 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.13 + +* Unify HTTP client **1** +* Add Apple HTTP and TLS engines **2** +* Unify HTTP/2 and QUIC parameters **3** +* Add TLS spoof **4** +* Fixes and improvements + +**1**: + +The new top-level [`http_clients`](/configuration/shared/http-client/) +option defines reusable HTTP clients (engine, version, dialer, TLS, +HTTP/2 and QUIC parameters). Components that make outbound HTTP requests +— remote rule-sets, ACME and Cloudflare Origin CA certificate providers, +DERP `verify_client_url`, and the Tailscale `control_http_client` — now +accept an inline HTTP client object or the tag of an `http_clients` +entry, replacing the dial and TLS fields previously inlined in each +component. When the field is omitted, ACME, Cloudflare Origin CA, DERP +and Tailscale dial direct (their existing default). + +Remote rule-sets are the only HTTP-using component whose default for an +omitted `http_client` has historically resolved to the default outbound, +not to direct, and a typical configuration contains many of them. To +avoid repeating the same `http_client` block in every rule-set, +[`route.default_http_client`](/configuration/route/#default_http_client) +selects a default rule-set client by tag and is the only field that +consults it. If `default_http_client` is empty and `http_clients` is +non-empty, the first entry is used automatically. The legacy fallback +(use the default outbound when `http_clients` is empty altogether) is +preserved with a deprecation warning and will be removed in sing-box +1.16.0, together with the legacy `download_detour` remote rule-set +option and the legacy dialer fields on Tailscale endpoints. + +**2**: + +A new `apple` engine is available on Apple platforms in two independent +places: + +* [HTTP client `engine`](/configuration/shared/http-client/#engine) — + routes HTTP requests through `NSURLSession`. +* Outbound TLS [`engine`](/configuration/shared/tls/#engine) — routes + the TLS handshake through `Network.framework` for direct TCP TLS + client connections. + +The default remains `go`. Both engines come with additional CGO and +framework memory overhead and platform restrictions documented on each +field. + +**3**: + +[HTTP/2](/configuration/shared/http2/) and +[QUIC](/configuration/shared/quic/) parameters +(`idle_timeout`, `keep_alive_period`, `stream_receive_window`, +`connection_receive_window`, `max_concurrent_streams`, +`initial_packet_size`, `disable_path_mtu_discovery`) are now shared +across QUIC-based outbounds +([Hysteria](/configuration/outbound/hysteria/), +[Hysteria2](/configuration/outbound/hysteria2/), +[TUIC](/configuration/outbound/tuic/)) and HTTP clients running HTTP/2 +or HTTP/3. + +This deprecates the Hysteria v1 tuning fields `recv_window_conn`, +`recv_window`, `recv_window_client`, `max_conn_client` and +`disable_mtu_discovery`; they will be removed in sing-box 1.16.0. + +**4**: + +Added outbound TLS [`spoof`](/configuration/shared/tls/#spoof) and +[`spoof_method`](/configuration/shared/tls/#spoof_method) fields. When +enabled, a forged ClientHello carrying a whitelisted SNI is sent before +the real handshake to fool SNI-filtering middleboxes. Requires +`CAP_NET_RAW` + `CAP_NET_ADMIN` or root on Linux and macOS, and +Administrator privileges on Windows (ARM64 is not supported). IP-literal +server names are rejected. + #### 1.14.0-alpha.12 * Fix fake-ip DNS server should return SUCCESS when address type is not configured diff --git a/small/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua b/small/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua index 7f255ad91a..114aaa05ff 100644 --- a/small/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua +++ b/small/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua @@ -9,7 +9,7 @@ if not arg[1] or not m:get(arg[1]) then luci.http.redirect(m.redirect) end -m:append(Template(appname .. "/cbi/nodes_multivalue_com")) +m:append(Template(appname .. "/cbi/nodes_dynamiclist_com")) m:append(Template(appname .. "/cbi/nodes_listvalue_com")) local has_singbox = api.finded_com("sing-box") @@ -96,44 +96,28 @@ o.datatype = "min(1)" o.default = 1 o:depends("enable_autoswitch", true) -o = s:option(MultiValue, "autoswitch_backup_node", translate("List of backup nodes")) +o = s:option(DynamicList, "autoswitch_backup_node", translate("List of backup nodes")) o:depends("enable_autoswitch", true) -o.widget = "checkbox" -o.template = appname .. "/cbi/nodes_multivalue" +o.template = appname .. "/cbi/nodes_dynamiclist" o.group = {} +o.write = function(self, section, value) + local n = s.fields["node"]:formvalue(section) + local v = type(value) == "table" and value or { value } + local t = {} + for _, x in ipairs(v) do + if x and x ~= n then t[#t+1] = x end + end + return DynamicList.write(self, section, t) +end for i, v in pairs(nodes_table) do if v.protocol ~= "_shunt" then o:value(v.id, v.remark) - o.group[#o.group+1] = v.group or "" + o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default") s.fields["enable_autoswitch"]:depends({ node = v.id }) end socks_node:value(v.id, v["remark"]) socks_node.group[#socks_node.group+1] = (v.group and v.group ~= "") and v.group or translate("default") end --- 读取旧 DynamicList -function o.cfgvalue(self, section) - return m.uci:get_list(appname, section, "autoswitch_backup_node") or {} -end --- 写入保持 DynamicList -function o.write(self, section, value) - local old = m.uci:get_list(appname, section, "autoswitch_backup_node") or {} - local new, set = {}, {} - for v in value:gmatch("%S+") do - new[#new + 1] = v - set[v] = 1 - end - for _, v in ipairs(old) do - if not set[v] then - m.uci:set_list(appname, section, "autoswitch_backup_node", new) - return - end - set[v] = nil - end - for _ in pairs(set) do - m.uci:set_list(appname, section, "autoswitch_backup_node", new) - return - end -end o = s:option(Flag, "autoswitch_restore_switch", translate("Restore Switch"), translate("When detects main node is available, switch back to the main node.")) o:depends("enable_autoswitch", true) diff --git a/small/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist.htm b/small/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist.htm new file mode 100644 index 0000000000..f516acebe1 --- /dev/null +++ b/small/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist.htm @@ -0,0 +1,149 @@ +<%+cbi/valueheader%> +<% +-- Template Developers: +-- - lwb1978 +-- - snowie2000 +-- Copyright: copyright(c)2025–2027 +-- Description: Passwall(2) UI template + +local json = require "luci.jsonc" + +-- 读取值(keylist/vallist/group) +local values = {} +for i, key in pairs(self.keylist) do + values[#values + 1] = { + key = key, + label = self.vallist[i] or key, + group = self.group and self.group[i] or nil + } +end + +-- 获取当前配置值 +local current_key = {} +local cval = self:cfgvalue(section) +if type(cval) == "table" then + for _, v in ipairs(cval) do + for item in string.gmatch(v, "[^,]+") do + current_key[#current_key+1] = item + end + end +elseif type(cval) == "string" then + for item in string.gmatch(cval, "[^,]+") do + current_key[#current_key+1] = item + end +end + +-- 分离无组节点(ungrouped)与有组节点(grouped),并保持原有顺序 +local ungrouped = {} +local groups = {} +local group_order = {} +for _, item in ipairs(values) do + if not item.group or item.group == "" then + table.insert(ungrouped, item) + else + local g = item.group + if not groups[g] then + groups[g] = {} + table.insert(group_order, g) + end + table.insert(groups[g], item) + end +end + +-- 打包数据给 JS +local dropdown_data = { + current_key = current_key, + ungrouped = ungrouped, + groups = groups, + group_order = group_order, + cbid = cbid, +} +%> + + +
+ + + +
+ + +
+ + <%=translate("-- Please choose --")%> + + +
+ + +
+ + + +<%+cbi/valuefooter%> diff --git a/small/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist_com.htm b/small/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist_com.htm new file mode 100644 index 0000000000..74f6e31ec6 --- /dev/null +++ b/small/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist_com.htm @@ -0,0 +1,654 @@ +<% +-- Template Developers: +-- - lwb1978 +-- - snowie2000 +-- Copyright: copyright(c)2025–2027 +-- Description: Passwall(2) UI template +-- It is the common part of the template and cannot be used independently +%> + + + + diff --git a/small/v2ray-geodata/Makefile b/small/v2ray-geodata/Makefile index 97fa86508a..1b9d47e3aa 100644 --- a/small/v2ray-geodata/Makefile +++ b/small/v2ray-geodata/Makefile @@ -21,13 +21,13 @@ define Download/geoip HASH:=16dbd19ff8dddb69960f313a3b0c0623cae82dc9725687110c28740226d3b285 endef -GEOSITE_VER:=20260416090918 +GEOSITE_VER:=20260417103656 GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER) define Download/geosite URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/ URL_FILE:=dlc.dat FILE:=$(GEOSITE_FILE) - HASH:=0a08328675a38c0a4c0cf3ffa3c0fd4538d15469073763851b033ab73b63603a + HASH:=e99ce48793a85e3ae66d0234414d20c7c469c5adfc123d5d7becd027c40f4dfa endef GEOSITE_IRAN_VER:=202604130112 diff --git a/v2rayn/.github/workflows/build-linux.yml b/v2rayn/.github/workflows/build-linux.yml index b28cd8ee23..fe3af4a974 100644 --- a/v2rayn/.github/workflows/build-linux.yml +++ b/v2rayn/.github/workflows/build-linux.yml @@ -185,7 +185,7 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) runs-on: ubuntu-24.04-riscv - container: ghcr.io/xujiegb/fedora-riscv:43-latest + container: rockylinux/rockylinux:10 env: RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }} @@ -196,8 +196,8 @@ jobs: set -euo pipefail dnf -y makecache dnf -y install \ - sudo git rpm-build rpmdevtools dnf-plugins-core rsync findutils tar gzip unzip which curl jq wget file \ - ca-certificates desktop-file-utils xdg-utils python3 gcc make glibc-devel kernel-headers libatomic libstdc++ + sudo git rpm-build rpmdevtools dnf-plugins-core \ + rsync findutils tar gzip unzip which jq - name: Checkout repo (for scripts) shell: bash diff --git a/v2rayn/package-rhel-riscv.sh b/v2rayn/package-rhel-riscv.sh index e06556e26c..ef22934c38 100644 --- a/v2rayn/package-rhel-riscv.sh +++ b/v2rayn/package-rhel-riscv.sh @@ -37,7 +37,7 @@ DOTNET_RISCV_VERSION="10.0.105" DOTNET_RISCV_BASE="https://github.com/filipnavara/dotnet-riscv/releases/download" DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz" DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}" -SKIA_VER="${SKIA_VER:-3.119.1}" +SKIA_VER="${SKIA_VER:-3.119.2}" HARFBUZZ_VER="${HARFBUZZ_VER:-8.3.1.1}" # If the first argument starts with --, do not treat it as a version number @@ -111,9 +111,9 @@ build_sqlite_native_riscv64() { mkdir -p "$outdir" workdir="$(mktemp -d)" - # SQLite 3.49.1 amalgamation - sqlite_year="2025" - sqlite_ver="3490100" + # SQLite 3.51.3 amalgamation + sqlite_year="2026" + sqlite_ver="3510300" sqlite_zip="sqlite-amalgamation-${sqlite_ver}.zip" echo "[+] Download SQLite amalgamation: ${sqlite_zip}" diff --git a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/GlobalHotKeys.csproj b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/GlobalHotKeys.csproj index 98f39ac4ce..4eb18c6ba1 100644 --- a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/GlobalHotKeys.csproj +++ b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/GlobalHotKeys.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + true diff --git a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/HotKeyManager.cs b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/HotKeyManager.cs index d619597d8d..c612fb2f15 100644 --- a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/HotKeyManager.cs +++ b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/HotKeyManager.cs @@ -1,5 +1,6 @@ using System.Reactive.Linq; using System.Reactive.Subjects; +using System.Runtime.InteropServices; namespace GlobalHotKeys; @@ -42,7 +43,7 @@ public class HotKeyManager : IDisposable var wndProcDelegate = new WndProc(MessageHandler); // Convert the WndProc delegate into a structure. - var wndClassEx = WNDCLASSEX.FromWndProc(wndProcDelegate); + var wndClassEx = WNDCLASSEX.FromWndProc(wndProcDelegate, out var classNamePtr); // Register the window class. var registeredClass = NativeFunctions.RegisterClassEx(ref wndClassEx); @@ -59,6 +60,9 @@ public class HotKeyManager : IDisposable // cleanup the resources after wards. Cleanup(localHWnd); + // Keep the delegate alive until after cleanup to prevent GC collection during the message loop. + GC.KeepAlive(wndProcDelegate); + return; // nextId: find the next free id from 0x0000 to 0xBFFF. @@ -163,7 +167,8 @@ public class HotKeyManager : IDisposable } NativeFunctions.DestroyWindow(hWnd); - NativeFunctions.UnregisterClass(wndClassEx.lpszClassName, hInstance); + NativeFunctions.UnregisterClass(classNamePtr, hInstance); + Marshal.FreeHGlobal(classNamePtr); } } } diff --git a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeFunctions.cs b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeFunctions.cs index f45fc486cd..2317158df8 100644 --- a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeFunctions.cs +++ b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeFunctions.cs @@ -2,44 +2,63 @@ namespace GlobalHotKeys; -public class NativeFunctions +public partial class NativeFunctions { - [DllImport("user32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall)] - public static extern bool RegisterHotKey(IntPtr hWnd, int id, Modifiers fsModifiers, VirtualKeyCode vk); + [LibraryImport("user32.dll", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool RegisterHotKey(IntPtr hWnd, int id, Modifiers fsModifiers, VirtualKeyCode vk); - [DllImport("user32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall)] - public static extern bool UnregisterHotKey(IntPtr hWnd, int id); + [LibraryImport("user32.dll", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool UnregisterHotKey(IntPtr hWnd, int id); - [DllImport("user32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall)] - public static extern IntPtr DefWindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); + [LibraryImport("user32.dll", EntryPoint = "DefWindowProcW", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + public static partial IntPtr DefWindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); - [DllImport("user32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall)] - public static extern int RegisterClassEx(ref WNDCLASSEX lpwcx); + [LibraryImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + public static partial int RegisterClassEx(ref WNDCLASSEX lpwcx); - [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)] - public static extern IntPtr CreateWindowEx(int dwExStyle, uint regResult, string lpWindowName, WindowStyle dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); + [LibraryImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + public static partial IntPtr CreateWindowEx(int dwExStyle, uint regResult, string lpWindowName, WindowStyle dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); - [DllImport("user32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall)] - public static extern bool DestroyWindow(IntPtr hWnd); + [LibraryImport("user32.dll", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool DestroyWindow(IntPtr hWnd); - [DllImport("user32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall)] - public static extern bool UnregisterClass(string lpClassName, IntPtr hInstance); + [LibraryImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool UnregisterClass(IntPtr lpClassName, IntPtr hInstance); - [DllImport("user32.dll", CallingConvention = CallingConvention.StdCall)] - public static extern int GetMessage(ref TagMSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); + [LibraryImport("user32.dll", EntryPoint = "GetMessageW")] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + public static partial int GetMessage(ref TagMSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); - [DllImport("user32.dll", CallingConvention = CallingConvention.StdCall)] - public static extern bool TranslateMessage(ref TagMSG lpMsg); + [LibraryImport("user32.dll")] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool TranslateMessage(ref TagMSG lpMsg); - [DllImport("user32.dll", CallingConvention = CallingConvention.StdCall)] - public static extern IntPtr DispatchMessage(ref TagMSG lpmsg); + [LibraryImport("user32.dll", EntryPoint = "DispatchMessageW")] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + public static partial IntPtr DispatchMessage(ref TagMSG lpmsg); - [DllImport("kernel32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall)] - public static extern IntPtr GetModuleHandle(string? lpModuleName); + [LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + public static partial IntPtr GetModuleHandle(string? lpModuleName); - [DllImport("user32.dll", CallingConvention = CallingConvention.StdCall)] - public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + [LibraryImport("user32.dll", EntryPoint = "SendMessageW")] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + public static partial IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); - [DllImport("user32.dll", CallingConvention = CallingConvention.StdCall)] - public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + [LibraryImport("user32.dll", EntryPoint = "PostMessageW")] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); } diff --git a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeTypes.cs b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeTypes.cs index 60da241035..e1bd09fd0d 100644 --- a/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeTypes.cs +++ b/v2rayn/v2rayN/GlobalHotKeys/src/GlobalHotKeys/NativeTypes.cs @@ -28,32 +28,33 @@ public struct WNDCLASSEX { public uint cbSize; public uint style; - public WndProc lpfnWndProc; + public IntPtr lpfnWndProc; public int cbClsExtra; public int cbWndExtra; public IntPtr hInstance; public IntPtr hIcon; public IntPtr hCursor; public IntPtr hbrBackground; - public string? lpszMenuName; - public string lpszClassName; + public IntPtr lpszMenuName; + public IntPtr lpszClassName; public IntPtr hIconSm; - public static WNDCLASSEX FromWndProc(WndProc wndProc) + public static WNDCLASSEX FromWndProc(WndProc wndProc, out IntPtr classNamePtr) { + classNamePtr = Marshal.StringToHGlobalUni("GlobalHotKeyWindowClass_" + Guid.NewGuid().ToString()); return new WNDCLASSEX { cbSize = (uint)Marshal.SizeOf(typeof(WNDCLASSEX)), style = 0, - lpfnWndProc = wndProc, + lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc), cbClsExtra = 0, cbWndExtra = 0, hInstance = NativeFunctions.GetModuleHandle(null), hIcon = IntPtr.Zero, hCursor = IntPtr.Zero, hbrBackground = IntPtr.Zero, - lpszMenuName = null, - lpszClassName = "GlobalHotKeyWindowClass_" + Guid.NewGuid().ToString(), + lpszMenuName = IntPtr.Zero, + lpszClassName = classNamePtr, hIconSm = IntPtr.Zero }; } diff --git a/v2rayn/v2rayN/ServiceLib/Common/WindowsUtils.cs b/v2rayn/v2rayN/ServiceLib/Common/WindowsUtils.cs index d801c34384..6cd47f0a80 100644 --- a/v2rayn/v2rayN/ServiceLib/Common/WindowsUtils.cs +++ b/v2rayn/v2rayN/ServiceLib/Common/WindowsUtils.cs @@ -53,12 +53,12 @@ internal static class WindowsUtils public static async Task RemoveTunDevice() { - var tunNameList = new List { "singbox_tun", "xray_tun" }; + var tunNameList = new List { "wintunsingbox_tun", "xray_tun" }; foreach (var tunName in tunNameList) { try { - var sum = MD5.HashData(Encoding.UTF8.GetBytes($"wintun{tunName}")); + var sum = MD5.HashData(Encoding.UTF8.GetBytes(tunName)); var guid = new Guid(sum); var pnpUtilPath = @"C:\Windows\System32\pnputil.exe"; var arg = $$""" /remove-device "SWD\Wintun\{{{guid}}}" """; diff --git a/v2rayn/v2rayN/ServiceLib/Resx/ResUI.fr.resx b/v2rayn/v2rayN/ServiceLib/Resx/ResUI.fr.resx index 907eb02c1e..8540727fe7 100644 --- a/v2rayn/v2rayN/ServiceLib/Resx/ResUI.fr.resx +++ b/v2rayn/v2rayN/ServiceLib/Resx/ResUI.fr.resx @@ -1318,7 +1318,7 @@ Adresse sortante locale (SendThrough) - Pour environnement multi-interfaces, veuillez saisir l’adresse IPv4 de la machine locale. + Pour environnements multi-interfaces, entrez l'adresse IPv4 de la machine locale. Veuillez saisir l’adresse IPv4 correcte de SendThrough. @@ -1701,9 +1701,6 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Protection TUN héritée - - Pour environnements multi-interfaces, entrez l’adresse IPv4 de la machine locale. - Domaine de camouflage diff --git a/v2rayn/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml.cs b/v2rayn/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml.cs index 26e5564685..6501468de8 100644 --- a/v2rayn/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml.cs +++ b/v2rayn/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml.cs @@ -99,7 +99,8 @@ public partial class OptionSettingWindow : WindowBase this.Bind(ViewModel, vm => vm.SpeedPingTestUrl, v => v.cmbSpeedPingTestUrl.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.MixedConcurrencyCount, v => v.cmbMixedConcurrencyCount.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SubConvertUrl, v => v.cmbSubConvertUrl.Text).DisposeWith(disposables); - this.Bind(ViewModel, vm => vm.MainGirdOrientation, v => v.cmbMainGirdOrientation.SelectedIndex).DisposeWith(disposables); + this.Bind(ViewModel, + vm => vm.MainGirdOrientation, view => view.cmbMainGirdOrientation.SelectedIndex).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.GeoFileSourceUrl, v => v.cmbGetFilesSourceUrl.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SrsFileSourceUrl, v => v.cmbSrsFilesSourceUrl.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.RoutingRulesSourceUrl, v => v.cmbRoutingRulesSourceUrl.SelectedValue).DisposeWith(disposables); diff --git a/v2rayng/V2rayNG/app/build.gradle.kts b/v2rayng/V2rayNG/app/build.gradle.kts index a2715be182..61caf566b6 100644 --- a/v2rayng/V2rayNG/app/build.gradle.kts +++ b/v2rayng/V2rayNG/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.v2ray.ang" minSdk = 24 targetSdk = 36 - versionCode = 718 - versionName = "2.0.18" + versionCode = 720 + versionName = "2.1.0" multiDexEnabled = true val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt index 56d68e8678..f5c66ee6bc 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt @@ -10,8 +10,8 @@ object AppConfig { /** Directory names used in the app's file system. */ const val DIR_ASSETS = "assets" - const val WEBDAV_BACKUP_DIR = "backups" - const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip" + const val WEBDAV_BACKUP_DIR = "backups" + const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip" /** Legacy configuration keys. */ const val ANG_CONFIG = "ang_config" @@ -124,7 +124,8 @@ object AppConfig { const val TG_CHANNEL_URL = "https://t.me/github_2dust" const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204" const val DELAY_TEST_URL2 = "https://www.google.com/generate_204" -// const val IP_API_URL = "https://speed.cloudflare.com/meta" + + // const val IP_API_URL = "https://speed.cloudflare.com/meta" const val IP_API_URL = "https://api.ip.sb/geoip" /** DNS server addresses. */ @@ -278,4 +279,9 @@ object AppConfig { "Chocolate4U/Iran-v2ray-rules" ) + val BUILTIN_OUTBOUND_TAGS = setOf( + TAG_PROXY, + TAG_DIRECT, + TAG_BLOCKED, + ) } diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/MainAdapterListener.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/MainAdapterListener.kt index f2d072340a..4feab49617 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/MainAdapterListener.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/MainAdapterListener.kt @@ -2,7 +2,7 @@ package com.v2ray.ang.contracts import com.v2ray.ang.dto.ProfileItem -interface MainAdapterListener :BaseAdapterListener { +interface MainAdapterListener : BaseAdapterListener { fun onEdit(guid: String, position: Int, profile: ProfileItem) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/Tun2SocksControl.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/Tun2SocksControl.kt index a551cd161c..dd8ecdc587 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/Tun2SocksControl.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/Tun2SocksControl.kt @@ -11,7 +11,7 @@ interface Tun2SocksControl { * This initializes the VPN tunnel and connects it to the SOCKS proxy. */ fun startTun2Socks() - + /** * Stops the tun2socks process and cleans up resources. */ diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt index 117786e81b..b65d6827cd 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt @@ -318,6 +318,7 @@ data class V2rayConfig( ) } } + data class QuicParamsBean( var congestion: String? = null, var brutalUp: String? = null, diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/enums/EConfigType.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/enums/EConfigType.kt index b27feb6449..f849e759cd 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/enums/EConfigType.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/enums/EConfigType.kt @@ -15,7 +15,7 @@ enum class EConfigType(val value: Int, val protocolScheme: String) { HYSTERIA2(9, AppConfig.HYSTERIA2), HYSTERIA(900, AppConfig.HYSTERIA), HTTP(10, AppConfig.HTTP), - POLICYGROUP (101, AppConfig.CUSTOM); + POLICYGROUP(101, AppConfig.CUSTOM); companion object { fun fromInt(value: Int) = entries.firstOrNull { it.value == value } diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt index d574dae334..610500b430 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt @@ -12,7 +12,7 @@ object CustomFmt : FmtBase() { * @param str the JSON string to parse * @return the parsed ProfileItem object, or null if parsing fails */ - fun parse(str: String): ProfileItem? { + fun parse(str: String): ProfileItem { val config = ProfileItem.create(EConfigType.CUSTOM) val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt index 5e3a9671fd..ce5b6fd00e 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt @@ -20,7 +20,7 @@ object Hysteria2Fmt : FmtBase() { * @param str the Hysteria2 URI string to parse * @return the parsed ProfileItem object, or null if parsing fails */ - fun parse(str: String): ProfileItem? { + fun parse(str: String): ProfileItem { var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false) val config = ProfileItem.create(EConfigType.HYSTERIA2) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt index c05d5544a7..39ec01093e 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt @@ -1,6 +1,5 @@ package com.v2ray.ang.fmt -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.V2rayConfig.OutboundBean @@ -8,6 +7,7 @@ import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.NetworkType import com.v2ray.ang.extension.idnHost import com.v2ray.ang.handler.V2rayConfigManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import java.net.URI diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt index e5ee0549bf..f5de509dc5 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt @@ -18,7 +18,7 @@ object TrojanFmt : FmtBase() { * @param str the Trojan URI string to parse * @return the parsed ProfileItem object, or null if parsing fails */ - fun parse(str: String): ProfileItem? { + fun parse(str: String): ProfileItem { var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false) val config = ProfileItem.create(EConfigType.TROJAN) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt index 1b1af3e682..065519f31c 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt @@ -1,7 +1,6 @@ package com.v2ray.ang.fmt import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.V2rayConfig.OutboundBean @@ -13,6 +12,7 @@ import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import java.net.URI diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt index 83c66e4681..be27c3d018 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt @@ -46,7 +46,7 @@ object WireguardFmt : FmtBase() { * @param str the Wireguard configuration file string to parse * @return the parsed ProfileItem object, or null if parsing fails */ - fun parseWireguardConfFile(str: String): ProfileItem? { + fun parseWireguardConfFile(str: String): ProfileItem { val config = ProfileItem.create(EConfigType.WIREGUARD) val interfaceParams: MutableMap = mutableMapOf() diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt index 0ae0c7e80b..90fb96b304 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.handler import android.content.Context import android.graphics.Bitmap import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.HY2 import com.v2ray.ang.R @@ -23,6 +22,7 @@ import com.v2ray.ang.fmt.VmessFmt import com.v2ray.ang.fmt.WireguardFmt import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.QRCodeDecoder import com.v2ray.ang.util.Utils import java.net.URI diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt index c33b9ebd6e..1fcfa8dab4 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt @@ -344,7 +344,7 @@ object MmkvManager { //region Subscriptions - private fun getSubscriptionId(subscriptionId: String?):String { + private fun getSubscriptionId(subscriptionId: String?): String { return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID } @@ -374,7 +374,7 @@ object MmkvManager { decodeSubsList().forEach { key -> val json = subStorage.decodeString(key) if (!json.isNullOrBlank()) { - val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem() + val item = JsonUtil.fromJson(json, SubscriptionItem::class.java) ?: SubscriptionItem() subscriptions.add(SubscriptionCache(key, item)) } } @@ -442,7 +442,7 @@ object MmkvManager { return if (json.isNullOrBlank()) { mutableListOf() } else { - JsonUtil.fromJson(json, Array::class.java)?.toMutableList()?: mutableListOf() + JsonUtil.fromJson(json, Array::class.java)?.toMutableList() ?: mutableListOf() } } @@ -460,7 +460,7 @@ object MmkvManager { assetStorage.allKeys()?.forEach { key -> val json = assetStorage.decodeString(key) if (!json.isNullOrBlank()) { - val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem() + val item = JsonUtil.fromJson(json, AssetUrlItem::class.java) ?: AssetUrlItem() assetUrlItems.add(AssetUrlCache(key, item)) } } @@ -510,7 +510,7 @@ object MmkvManager { fun decodeRoutingRulesets(): MutableList? { val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET) if (ruleset.isNullOrEmpty()) return null - return JsonUtil.fromJson(ruleset, Array::class.java)?.toMutableList()?: mutableListOf() + return JsonUtil.fromJson(ruleset, Array::class.java)?.toMutableList() ?: mutableListOf() } /** diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/NotificationManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/NotificationManager.kt index 7e09d01bd5..51c66edb70 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/NotificationManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/NotificationManager.kt @@ -9,7 +9,6 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.os.Build -import com.v2ray.ang.util.LogUtil import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import com.v2ray.ang.AppConfig @@ -17,6 +16,7 @@ import com.v2ray.ang.R import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.extension.toSpeedString import com.v2ray.ang.ui.MainActivity +import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt index a233fbf181..b61c025833 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.AssetManager import android.os.Build import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import androidx.appcompat.app.AppCompatDelegate import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.ANG_PACKAGE @@ -28,6 +27,7 @@ import com.v2ray.ang.handler.MmkvManager.decodeSubscription import com.v2ray.ang.handler.MmkvManager.encodeSubscription import com.v2ray.ang.handler.MmkvManager.removeSubscription import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import java.io.File import java.io.FileOutputStream @@ -224,7 +224,7 @@ object SettingsManager { * @param toPosition The position to swap to. */ fun swapSubscriptions(fromPosition: Int, toPosition: Int) { - val subsList = MmkvManager.decodeSubsList() + val subsList = decodeSubsList() if (subsList.isEmpty()) return Collections.swap(subsList, fromPosition, toPosition) @@ -500,7 +500,7 @@ object SettingsManager { */ private fun ensureDefaultSettings() { // Write defaults in the exact order requested by the user - ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN) + ensureDefaultValue(AppConfig.PREF_MODE, VPN) ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN) ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString()) ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt index 073c746ab0..2d9639ee25 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt @@ -2,12 +2,12 @@ package com.v2ray.ang.handler import android.content.Context import android.os.SystemClock -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.dto.IPAPIInfo import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import java.io.IOException diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt index 2dcc4e5bf8..ce9e5305a1 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt @@ -5,13 +5,13 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build -import com.v2ray.ang.util.LogUtil import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.v2ray.ang.AppConfig import com.v2ray.ang.R +import com.v2ray.ang.util.LogUtil object SubscriptionUpdater { diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt index 4cc1608ac7..f4dd062d3e 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt @@ -2,7 +2,6 @@ package com.v2ray.ang.handler import android.content.Context import android.os.Build -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.BuildConfig import com.v2ray.ang.dto.CheckUpdateResult @@ -10,6 +9,7 @@ import com.v2ray.ang.dto.GitHubRelease import com.v2ray.ang.extension.concatUrl import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayNativeManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayNativeManager.kt index 0c62ba7f03..93038c3360 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayNativeManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayNativeManager.kt @@ -1,8 +1,8 @@ package com.v2ray.ang.handler import android.content.Context -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import go.Seq import libv2ray.CoreCallbackHandler diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayServiceManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayServiceManager.kt index f86a29bb61..90c4060ed8 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayServiceManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2RayServiceManager.kt @@ -9,7 +9,6 @@ import android.net.ConnectivityManager import android.os.Build import android.os.ParcelFileDescriptor import android.system.OsConstants -import com.v2ray.ang.util.LogUtil import androidx.core.content.ContextCompat import com.v2ray.ang.AppConfig import com.v2ray.ang.R @@ -19,8 +18,8 @@ import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.extension.toast import com.v2ray.ang.service.V2RayProxyOnlyService import com.v2ray.ang.service.V2RayVpnService +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.MessageUtil -import com.v2ray.ang.util.PackageUidResolver import com.v2ray.ang.util.Utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -379,7 +378,7 @@ object V2RayServiceManager { private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java) override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) return -1L + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L if (cm == null) return -1L val proto = when (network) { "tcp" -> OsConstants.IPPROTO_TCP diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt index eef992aafe..eeac9b6f3b 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt @@ -2,9 +2,9 @@ package com.v2ray.ang.handler import android.content.Context import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import com.google.gson.JsonArray import com.v2ray.ang.AppConfig +import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS import com.v2ray.ang.dto.ConfigResult import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.RulesetItem @@ -27,6 +27,7 @@ import com.v2ray.ang.fmt.VmessFmt import com.v2ray.ang.fmt.WireguardFmt import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.PackageUidResolver import com.v2ray.ang.util.Utils @@ -445,6 +446,45 @@ object V2rayConfigManager { } } + /** + * Pre-pass: scans all routing rulesets for non-builtin outbound tags, + * looks up the matching profile by remarks, converts it to an OutboundBean, + * and appends it to v2rayConfig.outbounds. This must run before getRouting + * so that every custom tag is already present when routing rules are applied. + * + * @param v2rayConfig The V2ray configuration object to be modified + */ + private fun injectCustomOutbounds(v2rayConfig: V2rayConfig) { + val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag } + val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return + + rulesetItems + .filter { it.enabled } + .mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } } + .filter { it !in BUILTIN_OUTBOUND_TAGS } + .distinct() + .forEach { tag -> + if (tag in existingTags) return@forEach + try { + val profile = SettingsManager.getServerViaRemarks(tag) ?: run { + LogUtil.w(AppConfig.TAG, "Custom outbound tag '$tag' not found by remarks, skipping") + return@forEach + } + val outbound = convertProfile2Outbound(profile) ?: run { + LogUtil.w(AppConfig.TAG, "Could not convert profile '$tag' to outbound, skipping") + return@forEach + } + updateOutboundWithGlobalSettings(outbound) + outbound.tag = tag + v2rayConfig.outbounds.add(outbound) + existingTags.add(tag) + LogUtil.d(AppConfig.TAG, "Injected custom outbound: tag='$tag'") + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Failed to inject custom outbound for tag '$tag', skipping", e) + } + } + } + /** * Configures routing settings for V2ray. * @@ -460,6 +500,9 @@ object V2rayConfigManager { MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: "AsIs" + // Pre-pass: inject outbounds referenced by remarks in routing rules + injectCustomOutbounds(v2rayConfig) + val rulesetItems = MmkvManager.decodeRoutingRulesets() rulesetItems?.forEach { key -> getRoutingUserRule(context, key, v2rayConfig) @@ -510,6 +553,16 @@ object V2rayConfigManager { rule.process = null } + // If the outbound tag is a custom one that failed to inject, fall back to proxy + val outboundTag = rule.outboundTag + if (!outboundTag.isNullOrBlank() + && outboundTag !in BUILTIN_OUTBOUND_TAGS + && v2rayConfig.outbounds.none { it.tag == outboundTag } + ) { + LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'") + rule.outboundTag = AppConfig.TAG_PROXY + } + v2rayConfig.routing.rules.add(rule) } catch (e: Exception) { @@ -568,7 +621,7 @@ object V2rayConfigManager { ) } - if(SettingsManager.isVpnMode()) { + if (SettingsManager.isVpnMode()) { if (SettingsManager.isUsingHevTun()) { //hev-socks5-tunnel dns routing v2rayConfig.routing.rules.add( @@ -888,13 +941,13 @@ object V2rayConfigManager { private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) { try { v2rayConfig.routing.rules.forEach { rule -> - if (rule.outboundTag == "proxy") { + if (rule.outboundTag == AppConfig.TAG_PROXY) { rule.outboundTag = null rule.balancerTag = AppConfig.TAG_BALANCER } } - val lstSelector = listOf("proxy-") + val lstSelector = listOf("proxy-") when (config.policyGroupType) { // Least Ping goto else "1" -> { @@ -917,6 +970,7 @@ object V2rayConfigManager { ) ) } + "2" -> { // Random val balancer = V2rayConfig.RoutingBean.BalancerBean( @@ -928,6 +982,7 @@ object V2rayConfigManager { ) v2rayConfig.routing.balancers = listOf(balancer) } + "3" -> { // Round Robin val balancer = V2rayConfig.RoutingBean.BalancerBean( @@ -939,6 +994,7 @@ object V2rayConfigManager { ) v2rayConfig.routing.balancers = listOf(balancer) } + else -> { // Default: Least Ping val balancer = V2rayConfig.RoutingBean.BalancerBean( @@ -1244,28 +1300,34 @@ object V2rayConfigManager { headerType == "wechat-video" -> "header-wechat" else -> "header-$headerType" } - udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean( - type = kcpHeaderType, - settings = if (headerType == "dns" && !host.isNullOrEmpty()) { - StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean( - domain = host - ) - } else { - null - } - )) + udpMaskList.add( + StreamSettingsBean.FinalMaskBean.MaskBean( + type = kcpHeaderType, + settings = if (headerType == "dns" && !host.isNullOrEmpty()) { + StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean( + domain = host + ) + } else { + null + } + ) + ) } if (seed.isNullOrEmpty()) { - udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean( - type = "mkcp-original" - )) - } else { - udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean( - type = "mkcp-aes128gcm", - settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean( - password = seed + udpMaskList.add( + StreamSettingsBean.FinalMaskBean.MaskBean( + type = "mkcp-original" ) - )) + ) + } else { + udpMaskList.add( + StreamSettingsBean.FinalMaskBean.MaskBean( + type = "mkcp-aes128gcm", + settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean( + password = seed + ) + ) + ) } streamSettings.finalmask = StreamSettingsBean.FinalMaskBean( udp = udpMaskList.toList() @@ -1356,7 +1418,7 @@ object V2rayConfigManager { if (start != null && end != null) { val minStart = maxOf(5, start) val minEnd = maxOf(minStart, end) - "$minStart-$minEnd" + "$minStart-$minEnd" } else { "30" } @@ -1426,7 +1488,7 @@ object V2rayConfigManager { allowInsecure = allowInsecure, serverName = sni.nullIfBlank(), fingerprint = profileItem.fingerPrint.nullIfBlank(), - alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() }, + alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() }, echConfigList = profileItem.echConfigList.nullIfBlank(), echForceQuery = profileItem.echForceQuery.nullIfBlank(), pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(), diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt index bfedd81db9..df1dd9ddb4 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt @@ -1,8 +1,8 @@ package com.v2ray.ang.handler -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.dto.WebDavConfig +import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Credentials diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt index cbf0fc2df5..0818ceb5e6 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt @@ -3,13 +3,13 @@ package com.v2ray.ang.helper import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri -import com.v2ray.ang.util.LogUtil import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.extension.toast +import com.v2ray.ang.util.LogUtil /** * Helper for choosing and creating files using Android Storage Access Framework. diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt index 5e6b3d7526..3d00f0eb47 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt @@ -3,10 +3,10 @@ package com.v2ray.ang.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.V2RayServiceManager +import com.v2ray.ang.util.LogUtil class BootReceiver : BroadcastReceiver() { /** diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt index cfa08d6218..add806f42a 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt @@ -1,8 +1,8 @@ package com.v2ray.ang.service import android.content.Context -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig +import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt index 9bfdb9c989..293097bd7f 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt @@ -7,11 +7,11 @@ import android.content.IntentFilter import android.graphics.drawable.Icon import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import com.v2ray.ang.util.LogUtil import androidx.core.content.ContextCompat import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.handler.V2RayServiceManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.Utils import java.lang.ref.SoftReference diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/TProxyService.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/TProxyService.kt index 67317137be..ecf81e701e 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/TProxyService.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/TProxyService.kt @@ -2,11 +2,11 @@ package com.v2ray.ang.service import android.content.Context import android.os.ParcelFileDescriptor -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.contracts.Tun2SocksControl import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.util.LogUtil import java.io.File /** @@ -22,9 +22,11 @@ class TProxyService( @JvmStatic @Suppress("FunctionName") private external fun TProxyStartService(configPath: String, fd: Int) + @JvmStatic @Suppress("FunctionName") private external fun TProxyStopService() + @JvmStatic @Suppress("FunctionName") private external fun TProxyGetStats(): LongArray? diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt index b1e60bbca2..284160ddee 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt @@ -4,11 +4,11 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.contracts.ServiceControl import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.V2RayServiceManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.MyContextWrapper import java.lang.ref.SoftReference diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt index 688f98869d..343e3f4b16 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt @@ -14,7 +14,6 @@ import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor import android.os.StrictMode -import com.v2ray.ang.util.LogUtil import androidx.annotation.RequiresApi import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.LOOPBACK @@ -25,6 +24,7 @@ import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.V2RayServiceManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.MyContextWrapper import com.v2ray.ang.util.Utils import java.lang.ref.SoftReference diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt index 7012f5c30b..f16ad52f43 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.app.AlertDialog import android.content.Intent import android.os.Bundle -import com.v2ray.ang.util.LogUtil import androidx.core.content.FileProvider import androidx.lifecycle.lifecycleScope import com.tencent.mmkv.MMKV @@ -20,6 +19,7 @@ import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.WebDavManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.ZipUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -38,7 +38,7 @@ class BackupActivity : HelperBaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - //setContentView(binding.root) + //setContentView(binding.root) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore)) binding.layoutBackup.setOnClickListener { diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseFragment.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseFragment.kt index 8938c16908..4d18416eb7 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseFragment.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseFragment.kt @@ -39,7 +39,7 @@ abstract class BaseFragment : Fragment() { * @param drawableResId The resource ID of the drawable to be used as the divider. * @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL). */ - fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) { + fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) { // Get the drawable from resources val drawable = ContextCompat.getDrawable(requireContext(), drawableResId) requireNotNull(drawable) { "Drawable resource not found" } diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt index b86c4a13ae..3a69803ac4 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt @@ -1,7 +1,6 @@ package com.v2ray.ang.ui import android.os.Bundle -import com.v2ray.ang.util.LogUtil import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import com.v2ray.ang.AppConfig @@ -15,6 +14,7 @@ import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.UpdateCheckerManager import com.v2ray.ang.handler.V2RayNativeManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import kotlinx.coroutines.launch @@ -58,8 +58,7 @@ class CheckUpdateActivity : BaseActivity() { } catch (e: Exception) { LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}") toastError(e.message ?: getString(R.string.toast_failure)) - } - finally { + } finally { hideLoading() } } diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupServerFragment.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupServerFragment.kt index d9d1bc7397..74b7043113 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupServerFragment.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupServerFragment.kt @@ -2,7 +2,6 @@ package com.v2ray.ang.ui import android.content.Intent import android.os.Bundle -import com.v2ray.ang.util.LogUtil import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -25,6 +24,7 @@ import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.helper.SimpleItemTouchHelperCallback +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.viewmodel.MainViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseActivity.kt index 43fc1076ea..a8bd39587d 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseActivity.kt @@ -20,9 +20,9 @@ import com.v2ray.ang.helper.QRCodeScannerHelper * - Provide QR code scanning helpers with camera permission handling. */ abstract class HelperBaseActivity : BaseActivity() { - private lateinit var fileChooser : FileChooserHelper - private lateinit var permissionRequester : PermissionHelper - private lateinit var qrCodeScanner : QRCodeScannerHelper + private lateinit var fileChooser: FileChooserHelper + private lateinit var permissionRequester: PermissionHelper + private lateinit var qrCodeScanner: QRCodeScannerHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt index cb2ade8b58..b77e870aec 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt @@ -1,11 +1,11 @@ package com.v2ray.ang.ui -import com.v2ray.ang.util.LogUtil import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.v2ray.ang.AppConfig import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.viewmodel.LogcatViewModel class LogcatRecyclerAdapter( diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt index be0719a39a..7df9bfd8b7 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt @@ -5,7 +5,6 @@ import android.content.res.ColorStateList import android.net.Uri import android.net.VpnService import android.os.Bundle -import com.v2ray.ang.util.LogUtil import android.view.KeyEvent import android.view.Menu import android.view.MenuItem @@ -33,6 +32,7 @@ import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.V2RayServiceManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.MainViewModel import kotlinx.coroutines.Dispatchers @@ -179,7 +179,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect binding.tvTestState.text = content } - private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) { + private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) { if (isLoading) { binding.fab.setImageResource(R.drawable.ic_fab_check) return diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt index 806d6323ab..ed3b9be9ee 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt @@ -113,7 +113,7 @@ class MainRecyclerAdapter( adapterListener?.onSelectServer(guid) } } - + } /** diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt index 5de52ec797..088e1d9a37 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.annotation.SuppressLint import android.os.Bundle import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import android.view.Menu import android.view.MenuItem import android.widget.Toast @@ -23,6 +22,7 @@ import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.util.AppManagerUtil import com.v2ray.ang.util.HttpUtil +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.PerAppProxyViewModel import es.dmoral.toasty.Toasty @@ -133,6 +133,7 @@ class PerAppProxyActivity : BaseActivity() { allowPerAppProxy() true } + R.id.invert_selection -> { invertSelection() allowPerAppProxy() diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt index 6f0d2e440e..e534845f59 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt @@ -11,7 +11,7 @@ import com.v2ray.ang.viewmodel.PerAppProxyViewModel class PerAppProxyAdapter( val apps: List, val viewModel: PerAppProxyViewModel -) :RecyclerView.Adapter() { +) : RecyclerView.Adapter() { companion object { private const val VIEW_TYPE_HEADER = 0 diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt index ab46b3d788..f20ce50482 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt @@ -3,14 +3,18 @@ package com.v2ray.ang.ui import android.os.Bundle import android.view.Menu import android.view.MenuItem +import android.widget.ArrayAdapter import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope +import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS +import com.v2ray.ang.AppConfig.TAG_PROXY import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivityRoutingEditBinding import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.util.Utils import kotlinx.coroutines.Dispatchers @@ -20,15 +24,12 @@ class RoutingEditActivity : BaseActivity() { private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) } private val position by lazy { intent.getIntExtra("position", -1) } - private val outbound_tag: Array by lazy { - resources.getStringArray(R.array.outbound_tag) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - //setContentView(binding.root) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title)) + setupOutboundTagInput() + val rulesetItem = SettingsManager.getRoutingRuleset(position) if (rulesetItem != null) { bindingServer(rulesetItem) @@ -41,6 +42,33 @@ class RoutingEditActivity : BaseActivity() { } } + /** + * Sets up the AutoCompleteTextView for outbound tag: + * suggestions = built-in tags (proxy/direct/block) + all existing profile remarks. + * The dropdown button triggers showing the full list without typing. + */ + private fun setupOutboundTagInput() { + val profileRemarks = MmkvManager.decodeAllServerList() + .mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks } + .filter { it.isNotBlank() } + + val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct() + val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions) + binding.spOutboundTag.setAdapter(adapter) + // threshold=0 means show all suggestions even before typing; still need focus+request + binding.spOutboundTag.threshold = 0 + + // Dropdown arrow button shows the full suggestion list + binding.btnOutboundTagDropdown.setOnClickListener { + binding.spOutboundTag.requestFocus() + binding.spOutboundTag.showDropDown() + } + // Also show on field click when it already has focus + binding.spOutboundTag.setOnClickListener { + binding.spOutboundTag.showDropDown() + } + } + private fun bindingServer(rulesetItem: RulesetItem): Boolean { binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks) binding.chkLocked.isChecked = rulesetItem.locked == true @@ -50,15 +78,14 @@ class RoutingEditActivity : BaseActivity() { binding.etPort.text = Utils.getEditable(rulesetItem.port) binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(",")) binding.etNetwork.text = Utils.getEditable(rulesetItem.network) - val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag) - binding.spOutboundTag.setSelection(outbound) - + // Set text directly; filter won't fire because we're not using setText(filter=true) + binding.spOutboundTag.setText(rulesetItem.outboundTag, false) return true } private fun clearServer(): Boolean { binding.etRemarks.text = null - binding.spOutboundTag.setSelection(0) + binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false) return true } @@ -74,7 +101,7 @@ class RoutingEditActivity : BaseActivity() { protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } port = binding.etPort.text.toString().nullIfBlank() network = binding.etNetwork.text.toString().nullIfBlank() - outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition] + outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY } } if (rulesetItem.remarks.isNullOrEmpty()) { diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt index 1da5ab9e62..335aa6c540 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle -import com.v2ray.ang.util.LogUtil import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels @@ -21,6 +20,7 @@ import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.RoutingSettingsViewModel import kotlinx.coroutines.Dispatchers diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt index f8bc137858..c603d88c1b 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.content.Intent import android.graphics.BitmapFactory import android.os.Bundle -import com.v2ray.ang.util.LogUtil import android.view.Menu import android.view.MenuItem import com.v2ray.ang.AppConfig @@ -11,6 +10,7 @@ import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivityNoneBinding import com.v2ray.ang.extension.toast import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.QRCodeDecoder import io.github.g00fy2.quickie.QRResult import io.github.g00fy2.quickie.ScanCustomCode @@ -18,7 +18,7 @@ import io.github.g00fy2.quickie.config.BarcodeFormat import io.github.g00fy2.quickie.config.ScannerConfig class ScannerActivity : HelperBaseActivity() { - private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) } + private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) } private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt index 92fdf0cb7e..3319dfb31a 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt @@ -2,7 +2,6 @@ package com.v2ray.ang.ui import android.os.Bundle import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AlertDialog @@ -18,6 +17,7 @@ import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils class ServerCustomConfigActivity : BaseActivity() { diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt index bead163758..ff4639e2c4 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt @@ -97,7 +97,7 @@ class ServerGroupActivity : BaseActivity() { config.subscriptionId = subscriptionId.orEmpty() } - config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}" + config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}" MmkvManager.encodeServerConfig(editGuid, config) toastSuccess(R.string.toast_success) diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt index d5b500c209..6e04500e93 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle -import com.v2ray.ang.util.LogUtil import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem @@ -21,6 +20,7 @@ import com.v2ray.ang.extension.toast import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.helper.SimpleItemTouchHelperCallback +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.QRCodeDecoder import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.SubscriptionsViewModel diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt index 311c3b173a..0c482499a9 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt @@ -5,9 +5,7 @@ import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView -import com.v2ray.ang.AppConfig import com.v2ray.ang.contracts.BaseAdapterListener import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding import com.v2ray.ang.helper.ItemTouchHelperAdapter diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt index a7f434bc77..1918c1cfbd 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.content.Intent import android.os.Bundle import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import android.view.Menu import android.view.MenuItem import android.view.View @@ -13,6 +12,7 @@ import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivityTaskerBinding import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.util.LogUtil class TaskerActivity : BaseActivity() { private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) } diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt index 84881b9fd4..d8358b9261 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.content.Intent import android.net.Uri import android.os.Bundle -import com.v2ray.ang.util.LogUtil import androidx.lifecycle.lifecycleScope import com.v2ray.ang.AppConfig import com.v2ray.ang.R @@ -11,6 +10,7 @@ import com.v2ray.ang.databinding.ActivityLogcatBinding import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toastError import com.v2ray.ang.handler.AngConfigManager +import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt index 6e06b9bfc0..c2f919dbd3 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.OpenableColumns -import com.v2ray.ang.util.LogUtil import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels @@ -22,6 +21,7 @@ import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.UserAssetViewModel import kotlinx.coroutines.Dispatchers diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt index 7f48d8ed1d..9c45bae256 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt @@ -2,7 +2,6 @@ package com.v2ray.ang.ui import android.os.Bundle import android.text.TextUtils -import com.v2ray.ang.util.LogUtil import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AlertDialog @@ -13,6 +12,7 @@ import com.v2ray.ang.dto.AssetUrlItem import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import java.io.File diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt index 8c2d8be716..863852db31 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt @@ -1,6 +1,5 @@ package com.v2ray.ang.util -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.BuildConfig @@ -128,7 +127,7 @@ object HttpUtil { * @throws IOException If an I/O error occurs. */ @Throws(IOException::class) - fun getUrlContentWithUserAgent(url: String?, userAgent: String?, timeout: Int = 15000, httpPort: Int = 0): String { + fun getUrlContentWithUserAgent(url: String?, userAgent: String?, timeout: Int = 15000, httpPort: Int = 0): String { var currentUrl = url var redirects = 0 val maxRedirects = 3 diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/PackageUidResolver.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/PackageUidResolver.kt index b9c310fd4d..42627c0578 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/PackageUidResolver.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/PackageUidResolver.kt @@ -2,7 +2,6 @@ package com.v2ray.ang.util import android.content.Context import android.content.pm.PackageManager -import com.v2ray.ang.util.LogUtil import java.util.concurrent.ConcurrentHashMap object PackageUidResolver { diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt index 7a20fb087b..10e2e0721c 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt @@ -11,7 +11,6 @@ import android.os.LocaleList import android.provider.Settings import android.text.Editable import android.util.Base64 -import com.v2ray.ang.util.LogUtil import android.util.Patterns import android.webkit.URLUtil import androidx.core.content.ContextCompat @@ -139,7 +138,7 @@ object Utils { * @param removePadding * @return The base64 encoded string, or an empty string if encoding fails. */ - fun encode(text: String, removePadding : Boolean = false): String { + fun encode(text: String, removePadding: Boolean = false): String { return try { var encoded = Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) if (removePadding) { diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt index 7a13f68c03..ffeb5c93c8 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt @@ -1,6 +1,5 @@ package com.v2ray.ang.util -import com.v2ray.ang.util.LogUtil import com.v2ray.ang.AppConfig import java.io.BufferedOutputStream import java.io.File diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt index 70d6f45790..c8ee8342c7 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt @@ -44,7 +44,7 @@ class LogcatViewModel : ViewModel() { logsetsAll.clear() filteredLogs = emptyList() } catch (e: IOException) { - LogUtil.e(AppConfig.TAG, "Failed to clear logcat", e) + LogUtil.e(AppConfig.TAG, "Failed to clear logcat", e) } } diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt index 8e986f4320..a234921735 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.AssetManager -import com.v2ray.ang.util.LogUtil import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData @@ -27,6 +26,7 @@ import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SpeedtestManager +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.Utils import kotlinx.coroutines.CoroutineScope diff --git a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt index aef584cd7f..902e325be8 100644 --- a/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt +++ b/v2rayng/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt @@ -1,6 +1,5 @@ package com.v2ray.ang.viewmodel -import com.v2ray.ang.util.LogUtil import androidx.lifecycle.ViewModel import com.v2ray.ang.AppConfig import com.v2ray.ang.dto.AssetUrlCache @@ -8,6 +7,7 @@ import com.v2ray.ang.dto.AssetUrlItem import com.v2ray.ang.extension.concatUrl import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.util.HttpUtil +import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import java.io.File import java.io.FileOutputStream diff --git a/v2rayng/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml b/v2rayng/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 0000000000..aba3c44d4a --- /dev/null +++ b/v2rayng/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/v2rayng/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml b/v2rayng/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml index 65329d37d8..d5ad605291 100644 --- a/v2rayng/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml +++ b/v2rayng/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml @@ -190,13 +190,33 @@ android:layout_height="wrap_content" android:text="@string/routing_settings_outbound_tag" /> - + android:layout_marginBottom="@dimen/padding_spacing_dp8"> + + + + + + diff --git a/v2rayng/V2rayNG/app/src/main/res/values-ru/strings.xml b/v2rayng/V2rayNG/app/src/main/res/values-ru/strings.xml index 9926a18d88..be726045e4 100644 --- a/v2rayng/V2rayNG/app/src/main/res/values-ru/strings.xml +++ b/v2rayng/V2rayNG/app/src/main/res/values-ru/strings.xml @@ -112,7 +112,7 @@ Исходящая пропускная способность (допускаются: k/m/g/t) Режим XHTTP Необработанный JSON XHTTP Extra, формат: { XHTTPObject } - finalMask raw JSON, format: { FinalMaskObject } + Необработанный JSON FinalMask, формат: { FinalMaskObject } EchConfigList EchForceQuery Отпечаток сертификата (SHA-256) @@ -221,11 +221,11 @@ Порт локального прокси Порт локального прокси - Enable dynamic local proxy port - Generate a random local proxy port each time the inbound is created - Имя пользователя локального прокси (опционально) + Динамически менять порт + Использовать случайный порт локального прокси при каждом создании соединения + Пользователь локального прокси (необязательно) Имя пользователя - Пароль локального прокси (опционально) + Пароль локального прокси (необязательно) Пароль Порт локальной DNS diff --git a/v2rayng/V2rayNG/app/src/main/res/values/strings.xml b/v2rayng/V2rayNG/app/src/main/res/values/strings.xml index 81e875e497..1eb2d747c6 100644 --- a/v2rayng/V2rayNG/app/src/main/res/values/strings.xml +++ b/v2rayng/V2rayNG/app/src/main/res/values/strings.xml @@ -342,6 +342,7 @@ [http,tls,bittorrent] network [udp|tcp] + proxy / direct / block / <remarks> outboundTag Check Connectivity