Update On Fri Apr 17 21:15:10 CEST 2026

This commit is contained in:
github-action[bot]
2026-04-17 21:15:10 +02:00
parent 1072a5fb72
commit 3bd27b378d
143 changed files with 3413 additions and 842 deletions
+1
View File
@@ -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
+1 -3
View File
@@ -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),
-2
View File
@@ -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
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
-1
View File
@@ -31,7 +31,6 @@ func testSingMux(t *testing.T, tunnel *TestTunnel, out outbound.ProxyAdapter) {
singMuxOption := outbound.SingMuxOption{
Enabled: true,
Protocol: protocol,
TCPTimeout: 30, // set a timeout value that is slightly larger than the default
}
out, err := outbound.NewSingMux(singMuxOption, &notCloseProxyAdapter{out})
if !assert.NoError(t, err) {
+3 -3
View File
@@ -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)
}
}()
}
+12 -1
View File
@@ -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) {
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())
}
+3 -3
View File
@@ -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),
+155 -22
View File
@@ -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
}
wrc := NewWaitReadCloser()
go func() {
resp, err := transport.RoundTrip(req)
if err != nil {
_ = pr.Close()
_ = pw.Close()
httputils.CloseTransport(transport)
return nil, err
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)
return nil, fmt.Errorf("xhttp stream-one bad status: %s", resp.Status)
var buf [0]byte
_, err = wrc.Read(buf[:])
return nil, err
}
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)
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
wrc := NewWaitReadCloser()
go func() {
resp, err := downloadTransport.RoundTrip(downloadReq)
if err != nil {
httputils.CloseTransport(uploadTransport)
httputils.CloseTransport(downloadTransport)
return nil, err
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)
return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)
var buf [0]byte
_, err = wrc.Read(buf[:])
return nil, err
}
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
}
+17 -10
View File
@@ -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)
+3 -1
View File
@@ -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()
@@ -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
}
+33 -55
View File
@@ -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]]
@@ -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",
+2 -2
View File
@@ -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"
}
+1 -1
View File
@@ -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",
+58 -58
View File
@@ -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:
+3 -2
View File
@@ -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
+1 -3
View File
@@ -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),
-2
View File
@@ -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
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
-1
View File
@@ -31,7 +31,6 @@ func testSingMux(t *testing.T, tunnel *TestTunnel, out outbound.ProxyAdapter) {
singMuxOption := outbound.SingMuxOption{
Enabled: true,
Protocol: protocol,
TCPTimeout: 30, // set a timeout value that is slightly larger than the default
}
out, err := outbound.NewSingMux(singMuxOption, &notCloseProxyAdapter{out})
if !assert.NoError(t, err) {
+3 -3
View File
@@ -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)
}
}()
}
+12 -1
View File
@@ -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) {
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())
}
+3 -3
View File
@@ -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),
+155 -22
View File
@@ -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
}
wrc := NewWaitReadCloser()
go func() {
resp, err := transport.RoundTrip(req)
if err != nil {
_ = pr.Close()
_ = pw.Close()
httputils.CloseTransport(transport)
return nil, err
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)
return nil, fmt.Errorf("xhttp stream-one bad status: %s", resp.Status)
var buf [0]byte
_, err = wrc.Read(buf[:])
return nil, err
}
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)
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
wrc := NewWaitReadCloser()
go func() {
resp, err := downloadTransport.RoundTrip(downloadReq)
if err != nil {
httputils.CloseTransport(uploadTransport)
httputils.CloseTransport(downloadTransport)
return nil, err
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)
return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)
var buf [0]byte
_, err = wrc.Read(buf[:])
return nil, err
}
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
}
+17 -10
View File
@@ -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)
+3 -1
View File
@@ -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()
@@ -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
}
+1 -1
View File
@@ -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)?
+2 -2
View File
@@ -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
+7 -2
View File
@@ -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
+9 -1
View File
@@ -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
+30
View File
@@ -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
@@ -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)
}
})
}
@@ -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"
@@ -22,33 +22,45 @@ var errHTTP2Fallback = E.New("fallback to HTTP/1.1")
type http2FallbackTransport struct {
h2Transport *http2.Transport
h1Transport *http1Transport
h2Fallback *atomic.Bool
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,
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))
}
@@ -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")
}
}
+47 -23
View File
@@ -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
}
@@ -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("")
}
+14 -10
View File
@@ -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
}
+18 -14
View File
@@ -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 {
@@ -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 (*copy_fn != NULL) {
const char *value = (*copy_fn)(metadata);
if (value == NULL) {
*value_out = NULL;
return true;
}
if (get_fn != NULL) {
const char *protocol = get_fn(metadata);
if (protocol != NULL) {
return strdup(protocol);
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",
&copy_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",
&copy_fn,
&get_fn,
&onceToken,
value_out,
error_out
);
}
static NSArray<NSString *> *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);
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) {
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
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) {
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));
}
+11 -2
View File
@@ -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) {
+154
View File
@@ -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")
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+73
View File
@@ -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")
}
+3
View File
@@ -0,0 +1,3 @@
# tls spoof
idea from https://github.com/therealaleph/sni-spoofing-rust
+27 -76
View File
@@ -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
}
+57 -53
View File
@@ -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")
}
+255 -12
View File
@@ -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) {
+37 -8
View File
@@ -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,17 +71,23 @@ 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()
}
}()
do()
@@ -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)
@@ -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")
}
}
@@ -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)
@@ -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)
+26 -10
View File
@@ -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))
+59
View File
@@ -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")
}
+59 -24
View File
@@ -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)
}
// 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")
+17 -5
View File
@@ -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 {
+17 -13
View File
@@ -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
+15 -10
View File
@@ -52,28 +52,33 @@ func NewSpoofer(conn net.Conn, method Method) (Spoofer, error) {
type Conn struct {
net.Conn
spoofer Spoofer
fakeSNI string
fakeHello []byte
injected bool
}
func NewConn(conn net.Conn, spoofer Spoofer, fakeSNI string) *Conn {
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,
fakeSNI: fakeSNI,
}
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")
defer func() {
closeErr := c.spoofer.Close()
if err == nil && closeErr != nil {
err = E.Cause(closeErr, "tls_spoof: close spoofer")
}
err = c.spoofer.Inject(fake)
}()
err = c.spoofer.Inject(c.fakeHello)
if err != nil {
return 0, E.Cause(err, "tls_spoof: inject")
}
+5 -1
View File
@@ -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
}
@@ -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)))
+8 -1
View File
@@ -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
+13
View File
@@ -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
+75
View File
@@ -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
@@ -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)
@@ -0,0 +1,149 @@
<%+cbi/valueheader%>
<%
-- Template Developers:
-- - lwb1978
-- - snowie2000
-- Copyright: copyright(c)20252027
-- 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,
}
%>
<script>
window.dl_dropdown_data = window.dl_dropdown_data || {};
window.dl_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>;
</script>
<div id="<%=cbid%>.main" class="dl-dropdown-container">
<select id="<%=cbid%>.ref" class="cbi-input-select" style="display:none !important;">
<option value>placeholder</option>
</select>
<!-- 已选节点显示区 -->
<div id="<%=cbid%>.selected" class="dl-selected-list"></div>
<!-- 模拟 listvalue 控件外观 -->
<div class="cbi-input-value cbi-input-select dl-dropdown-display" id="<%=cbid%>.display" tabindex="0">
<span id="<%=cbid%>.label" class="dl-dropdown-label">
<%=translate("-- Please choose --")%>
</span>
<span class="dl-arrow-down"></span>
</div>
<!-- 下拉面板 -->
<div id="<%=cbid%>.panel" class="cbi-dynamiclist-panel dl-dropdown-panel" style="display:none;">
<!-- 搜索框 -->
<div style="padding:8px;border-bottom:1px solid #f0f0f0;">
<input id="<%=cbid%>.search" class="cbi-input-text dl-dropdown-search" type="text" placeholder="🔍 <%:Search nodes...%>" inputmode="search" enterkeyhint="done" />
</div>
<!-- 列表容器 -->
<div id="<%=cbid%>.list" style="padding:8px;">
<!-- 首次点击 display 时由 JS 填充 -->
</div>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function(){
const cbid = "<%=cbid%>";
const panel = document.getElementById(cbid + ".panel");
const display = document.getElementById(cbid + ".display");
const labelSpan = document.getElementById(cbid + ".label");
const searchInput = document.getElementById(cbid + ".search");
const listContainer = document.getElementById(cbid + ".list");
function init(){
const hidden = document.getElementById(cbid + ".hidden");
const data = window.dl_dropdown_data[cbid];
let arr = data.current_key;
if (!arr || arr.length === 0) return;
arr.forEach(key=>{
let label = "";
data.ungrouped.concat(...Object.values(data.groups)).forEach(v=>{
if (v.key == key) label = v.label;
});
if (label)
dl_add_item(cbid, key, label);
});
}
// 点击 display
display.addEventListener("click", function(e){
e.stopPropagation();
dl_render_dropdown_list(cbid,panel,listContainer,labelSpan,searchInput,display);
document.querySelectorAll(".cbi-dynamiclist-panel").forEach(p=>{
if (p !== panel) p.style.display = "none";
});
if (panel.style.display !== "none") {
dl_closePanel(cbid,panel,listContainer,searchInput);
} else {
dl_openPanel(cbid,display,panel,listContainer,searchInput);
}
});
dl_registerAdaptive(cbid);
init();
new Sortable(document.getElementById(cbid + ".selected"), {
animation: 150,
ghostClass: "dragging-row",
onEnd: function(){
dl_update_hidden(cbid);
}
});
})();
//]]>
</script>
<%+cbi/valuefooter%>
@@ -0,0 +1,654 @@
<%
-- Template Developers:
-- - lwb1978
-- - snowie2000
-- Copyright: copyright(c)20252027
-- Description: Passwall(2) UI template
-- It is the common part of the template and cannot be used independently
%>
<script src="<%=resource%>/view/passwall/Sortable.min.js"></script>
<style>
/* 主下拉按钮的下箭头 */
.dl-arrow-down {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #666;
margin-left: 6px;
display: inline-block;
vertical-align: middle;
}
/* 组标题的右箭头(折叠) */
.dl-arrow-right {
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid #555;
display: inline-block;
vertical-align: middle;
}
/* 组标题的下箭头(展开) */
.dl-arrow-down-small {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #555;
display: inline-block;
vertical-align: middle;
}
/* 基础列表项样式 */
.cbi-dynamiclist-panel li[data-key] {
padding: 6px;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background-color 0.15s ease;
}
/* 鼠标悬停效果 - 使用透明度避免覆盖问题 */
.cbi-dynamiclist-panel li[data-key]:hover {
background-color: rgba(0, 123, 255, 0.1);
}
.dl-dropdown-container {
display: inline-block;
position: relative;
min-width: 220px;
white-space: nowrap;
}
@media (max-width: 1152px) {
.dl-dropdown-container {
white-space: normal;
}
}
@media (max-width: 600px) {
.dl-dropdown-container {
display: block;
white-space: normal;
}
}
.dl-dropdown-display {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.dl-dropdown-label,
.dl-selected-label {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: left;
}
.dl-dropdown-panel {
position: fixed;
top: 0;
left: 0;
z-index: 2147483647;
border: 1px solid #dcdcdc;
border-radius: 4px;
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
max-height: 50vh;
overflow: auto;
overscroll-behavior: contain;
}
.dl-dropdown-search {
width: 100%;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box;
padding: 6px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.dl-group-title {
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
line-height: normal;
white-space: nowrap;
}
.dl-group-list {
list-style: none;
margin: 6px 0 0 8px;
padding: 0;
}
.dl-group-item {
padding: 6px;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left !important;
}
.dl-selected-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.dl-remove {
margin-left: 8px;
cursor: pointer;
color: red;
}
.dragging-row {
background-color: rgba(131, 191, 255, 0.7) !important;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
</style>
<script type="text/javascript">
//<![CDATA[
// css helper functions
function dl_camelToKebab(str) {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
}
function dl_style2Css(styleDeclaration, properties) {
const cssRules = properties.map(prop => {
const kebabCaseProp = dl_camelToKebab(prop);[1, 5]
const value = styleDeclaration[prop]
if (value) {
return `${kebabCaseProp}: ${value};`
}
return ''
})
// Filter out any empty strings and join the rules
return cssRules.filter(Boolean).join(' ')
}
const dl_parseColorToRgba = (function() {
// Create canvas and context once (Closure)
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
return function(colorStr) {
if (!colorStr)
return null;
ctx.clearRect(0, 0, 1, 1);
// 2. Apply the color
ctx.fillStyle = colorStr;
// 3. Fill a single pixel
ctx.fillRect(0, 0, 1, 1);
// 4. Extract pixel data [R, G, B, A]
const data = ctx.getImageData(0, 0, 1, 1).data;
return {
r: data[0],
g: data[1],
b: data[2],
// Convert alpha from 0-255 to 0-1 (rounded to 3 decimal places)
a: Math.round((data[3] / 255) * 1000) / 1000
};
};
})();
// Helper to convert back to Hex (for output consistency)
function dl_rgbToHex(r, g, b) {
const toHex = (n) => {
const hex = Math.max(0, Math.min(255, n)).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function dl_isTransparent(color) {
const cleanColor = dl_parseColorToRgba(color);
// check #RRGGBBAA for transparency
return !cleanColor || (cleanColor.a !== undefined && !cleanColor.a);
}
function dl_getColorSchema(color) {
const rgb = dl_parseColorToRgba(color);
if (!rgb) return 'unknown'; // Handle invalid colors
// Calculate YIQ brightness (human eye perception)
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
return brightness > 128 ? 'light' : 'dark';
}
function dl_lighter(color, amount) {
const rgb = dl_parseColorToRgba(color);
if (!rgb) return color;
// Add amount to each channel
const r = rgb.r + amount;
const g = rgb.g + amount;
const b = rgb.b + amount;
// Convert back to Hex (clamping happens inside rgbToHex)
return dl_rgbToHex(r, g, b);
}
function dl_darker(color, amount) {
const rgb = dl_parseColorToRgba(color);
if (!rgb) return color;
// Subtract amount from each channel
const r = rgb.r - amount;
const g = rgb.g - amount;
const b = rgb.b - amount;
return dl_rgbToHex(r, g, b);
}
// copy select styles
function dl_adaptiveStyle(cbid) {
const display = document.getElementById(cbid + ".display");
const hiddenRef = document.getElementById(cbid + ".ref");
const panel = document.getElementById(cbid + ".panel");
if (hiddenRef && display) {
const elOption = hiddenRef.getElementsByTagName("option")[0]
const styleSelect = window.getComputedStyle(hiddenRef)
const styleOption = window.getComputedStyle(elOption)
const styleBody = window.getComputedStyle(document.body)
const styleNode = document.createElement('style')
const styleNames = ["width", "color", "height", "padding", "margin", "lineHeight", "border", "borderRadius", "minWidth", "minHeight"]
if (styleSelect.borderBottomStyle !== "none") {
styleNames.push("borderBottomWidth", "borderBottomStyle", "borderBottomColor");
}
document.head.appendChild(styleNode)
// trace back from option -> select -> body for background color
const panelRadius = styleSelect.borderRadius;
const optionColor = !dl_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !dl_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor
const titleColor = dl_getColorSchema(optionColor) === "light" ? dl_darker(optionColor, 30) : dl_lighter(optionColor, 30)
const selectStyleCSS = [`#${CSS.escape(cbid + ".display")}, .dl-selected-item {`, dl_style2Css(styleSelect, styleNames), dl_style2Css(styleSelect, ["backgroundColor"]), "}"]
const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, dl_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"]
const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .dl-group-title {`, `background-color: ${titleColor} !important;`, "}"]
styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n")
}
}
function dl_idSafe(id) {
return id
.trim()
.replace(/\s+/g, "-")
.replace(/[\x00-\x1F\x7F]/g, "");
}
// 更新组内选中计数
function dl_updateGroupCounts(cbid, listContainer, searchInput) {
const groups = listContainer.querySelectorAll(".dl-group");
const isSearching = searchInput.value.trim() !== "";
groups.forEach(group => {
const gname = group.getAttribute("data-group");
const items = group.querySelectorAll("li[data-key]");
const span = document.getElementById("group-count-" + cbid + "-" + gname);
if (!span) return;
if (isSearching) {
// 搜索状态:显示匹配数量
let matchCount = 0;
items.forEach(li => {
if (li.style.display !== "none") matchCount++;
});
span.textContent = "(" + matchCount + "/" + items.length + ")";
if (matchCount > 0) {
span.style.color = "#28a745";
span.style.fontWeight = "600";
} else {
span.style.color = "#dc3545";
span.style.fontWeight = "normal";
}
} else {
// 默认状态
span.textContent = "(" + items.length + ")";
span.style.color = "";
span.style.fontWeight = "normal";
}
});
}
//搜索过滤器:按 name 或 label 做模糊匹配,搜索时自动展开所有组并隐藏不匹配条目
function dl_filterList(keyword, cbid, listContainer, searchInput) {
keyword = (keyword || "").toLowerCase().trim();
const topItems = listContainer.querySelectorAll("ul li[data-key]");
topItems.forEach(li=>{
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
if (!keyword || name.indexOf(keyword) !== -1) {
li.style.display = "block";
} else {
li.style.display = "none";
}
});
const groups = listContainer.querySelectorAll(".dl-group");
groups.forEach(group=>{
const items = group.querySelectorAll("li[data-key]");
let matchCount = 0;
items.forEach(li=>{
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
if (!keyword || name.indexOf(keyword) !== -1) {
li.style.display = "block";
matchCount++;
} else {
li.style.display = "none";
}
});
group.style.display = (matchCount === 0 && keyword !== "") ? "none" : "block";
const ul = group.querySelector(".dl-group-list");
const gname = group.getAttribute("data-group");
const arrow = document.getElementById("arrow-" + cbid + "-" + gname);
if (keyword) {
if (ul) ul.style.display = (matchCount > 0 ? "block" : "none");
if (arrow) arrow.className = (matchCount > 0 ? "dl-arrow-down-small" : "dl-arrow-right");
} else {
if (ul) ul.style.display = "none";
if (arrow) arrow.className = "dl-arrow-right";
}
});
dl_updateGroupCounts(cbid, listContainer, searchInput);
}
// 切换单个组(点击组标题)
function dl_toggleGroup(listContainer, cbid, g) {
g = dl_idSafe(g);
const group = listContainer.querySelector(".dl-group[data-group='" + g + "']");
if (!group) return;
const ul = group.querySelector(".dl-group-list");
const arrow = document.getElementById("arrow-" + cbid + "-" + g);
if (!ul) return;
const searchInput = document.getElementById(cbid + ".search");
const isSearching = searchInput?.value.trim() !== "";
const isExpanded = ul.style.display !== "none";
if (isExpanded) {
ul.style.display = "none";
if (arrow) arrow.className = "dl-arrow-right";
} else {
ul.style.display = "block";
if (arrow) arrow.className = "dl-arrow-down-small";
if (!isSearching) {
const allGroups = listContainer.querySelectorAll(".dl-group");
allGroups.forEach(otherGroup => {
if (otherGroup !== group) {
const otherUl = otherGroup.querySelector(".dl-group-list");
const otherGname = otherGroup.getAttribute("data-group");
const otherArrow = document.getElementById("arrow-" + cbid + "-" + otherGname);
if (otherUl) otherUl.style.display = "none";
if (otherArrow) otherArrow.className = "dl-arrow-right";
}
});
}
}
}
// 计算panel位置
function dl_repositionPanel(panel, display) {
if (!panel || panel.style.display === "none") return;
const rect = display.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
panel.style.visibility = "hidden";
panel.style.display = "block";
panel.style.minHeight = "100px";
panel.style.maxHeight = Math.min(0.5*viewportHeight, 550) + "px";
const panelHeight = panel.offsetHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
let top, isUp = false;
if (spaceBelow >= panelHeight) {
top = rect.bottom + 2;
isUp = false;
} else if (spaceAbove >= panelHeight) {
top = rect.top - panelHeight - 2;
isUp = true;
} else {
if (spaceBelow >= spaceAbove) {
top = Math.max(rect.bottom - 2, viewportHeight - panelHeight - 2);
isUp = false;
} else {
top = Math.min(rect.top - panelHeight + 2, 2);
isUp = true;
}
}
panel.style.left = rect.left + "px";
panel.style.top = top + "px";
const panelRect = panel.getBoundingClientRect();
const displayWidth = rect.width;
const remainingWidth = window.innerWidth - panelRect.left - 12;
const maxWidth = Math.max(displayWidth, Math.floor(remainingWidth));
panel.style.maxWidth = maxWidth + "px";
panel.style.minWidth = displayWidth + "px";
panel.style.width = "auto";
panel.style.visibility = "";
}
// 打开/关闭面板
function dl_openPanel(cbid, display, panel, listContainer, searchInput) {
if (!panel._moved) {
document.body.appendChild(panel);
panel._moved = true;
}
panel.style.display = "block";
dl_repositionPanel(panel, display);
// 失焦监听
const handler = function(e){
const target = e.target;
if (panel.style.display !== "none") {
if (!panel.contains(target) && !display.contains(target)) {
dl_closePanel(cbid, panel, listContainer, searchInput, display);
}
}
}
panel._docClickHandler = handler;
document.addEventListener("click", handler);
// 滚动 / resize 自动 reposition
let ticking = false;
const repositionHandler = function () {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () {
ticking = false;
dl_repositionPanel(panel, display);
});
};
panel._repositionHandler = repositionHandler;
window.addEventListener("scroll", repositionHandler, true);
window.addEventListener("resize", repositionHandler);
}
function dl_closePanel(cbid, panel, listContainer, searchInput) {
panel.style.display = "none";
searchInput.value = "";
dl_filterList("", cbid, listContainer, searchInput);
// document click
if (panel._docClickHandler) {
document.removeEventListener("click", panel._docClickHandler);
panel._docClickHandler = null;
}
// scroll / resize
if (panel._repositionHandler) {
window.removeEventListener("scroll", panel._repositionHandler, true);
window.removeEventListener("resize", panel._repositionHandler);
panel._repositionHandler = null;
}
}
function dl_add_item(cbid, key, label) {
const container = document.getElementById(cbid + ".selected");
const hidden = document.getElementById(cbid + ".hidden");
if (container.querySelector(`[data-key="${key}"]`)) return;
const item = document.createElement("div");
item.className = "cbi-input-value cbi-input-select dl-dropdown-display dl-selected-item";
item.setAttribute("data-key", key);
item.innerHTML = `
<span class="dl-selected-label" style="cursor:grab;" title="${label}">${label}</span>
<span class="dl-remove">×</span>
`;
item.querySelector(".dl-remove").onclick = function(){
item.remove();
dl_update_hidden(cbid);
};
container.appendChild(item);
dl_update_hidden(cbid);
}
function dl_update_hidden(cbid) {
const main = document.getElementById(cbid + ".main");
const container = document.getElementById(cbid + ".selected");
main.querySelectorAll(`input[type="hidden"][name="${cbid}"]`).forEach(el => el.remove());
let items = container.querySelectorAll(".dl-selected-item");
items.forEach(el => {
let key = el.getAttribute("data-key");
let input = document.createElement("input");
input.type = "hidden";
input.name = cbid;
input.value = key;
main.insertBefore(input, container);
});
}
// 动态生成下拉框
window.dl_dropdown_rendered = {};
function dl_escape_html(s) {
return s.replace(/[&<>"']/g, c => ({
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
}[c]));
}
function dl_render_dropdown_list(cbid, panel, listContainer, labelSpan, searchInput, display) {
if (window.dl_dropdown_rendered[cbid]) return;
const data = window.dl_dropdown_data[cbid];
if (!data) return;
if (!listContainer) return;
let html = "";
// 无组项
if (data.ungrouped && data.ungrouped.length > 0) {
html += `<ul style="list-style:none;padding:0;margin:0 0 8px 0;">`;
data.ungrouped.forEach(item => {
html += `
<li data-key="${item.key}"
data-node-name="${dl_escape_html(item.label.toLowerCase())}"
class="list-item">
<span class="dl-item-label" style="margin-left:12px;">
${dl_escape_html(item.label)}
</span>
</li>`;
});
html += `</ul>`;
}
// 分组项
data.group_order.forEach(gname => {
const items = data.groups[gname];
html += `
<div class="dl-group" data-group="${dl_idSafe(gname)}" style="margin-bottom:8px;">
<div class="dl-group-title" data-group-name="${dl_idSafe(gname)}">
<span class="dl-arrow-right" id="arrow-${cbid}-${dl_idSafe(gname)}"></span>
<b style="margin-left:6px;">${dl_escape_html(gname)}</b>
<span id="group-count-${cbid}-${dl_idSafe(gname)}"
style="margin-left:8px;">(${items.length})</span>
</div>
<ul id="group-${cbid}-${dl_idSafe(gname)}" class="dl-group-list" style="display:none">
`;
items.forEach(item => {
html += `
<li data-key="${item.key}"
data-node-name="${dl_escape_html(item.label.toLowerCase())}"
class="dl-group-item">
<span class="dl-item-label" title="${dl_escape_html(item.label)}">
${dl_escape_html(item.label)}
</span>
</li>`;
});
html += `
</ul>
</div>
`;
});
listContainer.innerHTML = html;
window.dl_dropdown_rendered[cbid] = true;
dl_adaptiveStyle(cbid);
// 点击项(无组与组内项都使用 li[data-key]
listContainer.addEventListener("click", function(e){
let li = e.target;
while(li && li !== listContainer && !li.hasAttribute('data-key')) li = li.parentNode;
if(!li || li === listContainer) return;
const key = li.getAttribute('data-key') || "";
const text = li.querySelector(".dl-item-label")?.textContent || li.textContent || key;
//改值
dl_add_item(cbid, key, text);
labelSpan.innerText = "<%=translate("-- Please choose --")%>";
dl_closePanel(cbid,panel,listContainer,searchInput);
});
// 搜索功能
searchInput.addEventListener("input", function() {
dl_filterList(this.value, cbid, listContainer, searchInput);
dl_repositionPanel(panel, display);
});
searchInput.addEventListener('keydown', function(e) {
const isEnter = e.key === "Enter" || e.keyCode === 13;
if (!isEnter) return;
e.stopPropagation();
e.preventDefault();
searchInput.blur();
});
// 切换组
listContainer.querySelectorAll(".dl-group-title").forEach(title => {
title.addEventListener("click", function() {
const g = this.closest(".dl-group")?.getAttribute("data-group");
if (g) {
dl_toggleGroup(listContainer, cbid, g);
dl_repositionPanel(panel, display);
}
});
});
// 防止 panel 惯性滚动穿透
panel.addEventListener('wheel', function (e) {
const deltaY = e.deltaY;
const scrollTop = panel.scrollTop;
const scrollHeight = panel.scrollHeight;
const clientHeight = panel.clientHeight;
const isAtTop = scrollTop === 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
if (deltaY < 0 && isAtTop) {
e.preventDefault();
return;
}
if (deltaY > 0 && isAtBottom) {
e.preventDefault();
return;
}
e.stopPropagation();
}, { passive: false });
}
const dl_adaptiveControls = new Set();
function dl_registerAdaptive(cbid) {
dl_adaptiveControls.add(cbid);
dl_adaptiveStyle(cbid);
}
let dl_adaptiveTicking = false;
window.addEventListener("resize", () => {
if (!dl_adaptiveTicking) {
dl_adaptiveTicking = true;
requestAnimationFrame(() => {
dl_adaptiveControls.forEach(cbid => {
dl_adaptiveStyle(cbid);
});
dl_adaptiveTicking = false;
});
}
});
//]]>
</script>
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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}"
@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
@@ -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);
}
}
}
@@ -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);
}
@@ -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
};
}
@@ -53,12 +53,12 @@ internal static class WindowsUtils
public static async Task RemoveTunDevice()
{
var tunNameList = new List<string> { "singbox_tun", "xray_tun" };
var tunNameList = new List<string> { "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}}}" """;
+1 -4
View File
@@ -1318,7 +1318,7 @@
<value>Adresse sortante locale (SendThrough)</value>
</data>
<data name="TbSettingsSendThroughTip" xml:space="preserve">
<value>Pour environnement multi-interfaces, veuillez saisir ladresse IPv4 de la machine locale.</value>
<value>Pour environnements multi-interfaces, entrez l'adresse IPv4 de la machine locale.</value>
</data>
<data name="FillCorrectSendThroughIPv4" xml:space="preserve">
<value>Veuillez saisir ladresse IPv4 correcte de SendThrough.</value>
@@ -1701,9 +1701,6 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbLegacyProtect" xml:space="preserve">
<value>Protection TUN héritée</value>
</data>
<data name="TbSettingsSendThroughTip" xml:space="preserve">
<value>Pour environnements multi-interfaces, entrez ladresse IPv4 de la machine locale.</value>
</data>
<data name="TbCamouflageDomain" xml:space="preserve">
<value>Domaine de camouflage</value>
</data>
@@ -99,7 +99,8 @@ public partial class OptionSettingWindow : WindowBase<OptionSettingViewModel>
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<OptionSettingViewModel, OptionSettingWindow, int, int>(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);
+2 -2
View File
@@ -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(';')
@@ -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,
)
}
@@ -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)
@@ -318,6 +318,7 @@ data class V2rayConfig(
)
}
}
data class QuicParamsBean(
var congestion: String? = null,
var brutalUp: String? = null,
@@ -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 }
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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<String, String> = mutableMapOf()
@@ -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
@@ -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<String>::class.java)?.toMutableList()?: mutableListOf()
JsonUtil.fromJson(json, Array<String>::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<RulesetItem>? {
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
if (ruleset.isNullOrEmpty()) return null
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList()?: mutableListOf()
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
}
/**
@@ -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
@@ -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)
@@ -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
@@ -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 {
@@ -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
@@ -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

Some files were not shown because too many files have changed in this diff Show More