mirror of
https://github.com/bolucat/Archive.git
synced 2026-04-22 16:07:49 +08:00
Update On Fri Apr 17 21:15:10 CEST 2026
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -29,9 +29,8 @@ func testSingMux(t *testing.T, tunnel *TestTunnel, out outbound.ProxyAdapter) {
|
||||
protocol := protocol
|
||||
t.Run(protocol, func(t *testing.T) {
|
||||
singMuxOption := outbound.SingMuxOption{
|
||||
Enabled: true,
|
||||
Protocol: protocol,
|
||||
TCPTimeout: 30, // set a timeout value that is slightly larger than the default
|
||||
Enabled: true,
|
||||
Protocol: protocol,
|
||||
}
|
||||
out, err := outbound.NewSingMux(singMuxOption, ¬CloseProxyAdapter{out})
|
||||
if !assert.NoError(t, err) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/common/httputils"
|
||||
@@ -95,6 +96,7 @@ type httpConn struct {
|
||||
writer io.Writer
|
||||
flusher http.Flusher
|
||||
body io.ReadCloser
|
||||
setupOnce sync.Once
|
||||
created chan struct{}
|
||||
createErr error
|
||||
cancelFn func()
|
||||
@@ -105,19 +107,28 @@ type httpConn struct {
|
||||
deadline *time.Timer
|
||||
}
|
||||
|
||||
func (h *httpConn) setUp(body io.ReadCloser, err error) {
|
||||
h.body = body
|
||||
h.createErr = err
|
||||
close(h.created)
|
||||
func (h *httpConn) setup(body io.ReadCloser, err error) {
|
||||
h.setupOnce.Do(func() {
|
||||
h.body = body
|
||||
h.createErr = err
|
||||
close(h.created)
|
||||
})
|
||||
if h.createErr != nil && body != nil { // conn already closed before setup
|
||||
_ = body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *httpConn) waitCreated() error {
|
||||
<-h.created
|
||||
if h.body != nil {
|
||||
return nil
|
||||
}
|
||||
return h.createErr
|
||||
}
|
||||
|
||||
func (h *httpConn) Close() error {
|
||||
var errorArr []error
|
||||
h.setup(nil, net.ErrClosed)
|
||||
if closer, ok := h.writer.(io.Closer); ok {
|
||||
errorArr = append(errorArr, closer.Close())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/metacubex/mihomo/common/httputils"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
"github.com/metacubex/http/httptrace"
|
||||
"github.com/metacubex/quic-go"
|
||||
"github.com/metacubex/quic-go/http3"
|
||||
"github.com/metacubex/tls"
|
||||
@@ -344,7 +345,21 @@ func (c *Client) DialStreamOne() (net.Conn, error) {
|
||||
|
||||
conn := &Conn{writer: pw}
|
||||
|
||||
req, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, c.ctx), http.MethodPost, requestURL.String(), pr)
|
||||
// Use gotConn to detect when TCP connection is established, so we can
|
||||
// return the conn immediately without waiting for the HTTP response.
|
||||
// This breaks the deadlock where CDN buffers response headers until the
|
||||
// server sends body data, but the server waits for our request body,
|
||||
// which can't be sent because we haven't returned the conn yet.
|
||||
gotConn := make(chan bool, 1)
|
||||
|
||||
addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx)
|
||||
ctx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
gotConn <- true
|
||||
},
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), pr)
|
||||
if err != nil {
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
@@ -360,21 +375,34 @@ func (c *Client) DialStreamOne() (net.Conn, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
wrc := NewWaitReadCloser()
|
||||
|
||||
go func() {
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
wrc.CloseWithError(err)
|
||||
close(gotConn)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
_ = resp.Body.Close()
|
||||
wrc.CloseWithError(fmt.Errorf("xhttp stream-one bad status: %s", resp.Status))
|
||||
return
|
||||
}
|
||||
wrc.Set(resp.Body)
|
||||
}()
|
||||
|
||||
if !<-gotConn {
|
||||
// RoundTrip failed before TCP connected (e.g. DNS failure)
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
httputils.CloseTransport(transport)
|
||||
var buf [0]byte
|
||||
_, err = wrc.Read(buf[:])
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
_ = resp.Body.Close()
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
httputils.CloseTransport(transport)
|
||||
return nil, fmt.Errorf("xhttp stream-one bad status: %s", resp.Status)
|
||||
}
|
||||
conn.reader = resp.Body
|
||||
|
||||
conn.reader = wrc
|
||||
conn.onClose = func() {
|
||||
_ = pr.Close()
|
||||
httputils.CloseTransport(transport)
|
||||
@@ -411,8 +439,18 @@ func (c *Client) DialStreamUp() (net.Conn, error) {
|
||||
|
||||
sessionID := newSessionID()
|
||||
|
||||
// Async download: avoid blocking on CDN response header buffering
|
||||
gotConn := make(chan bool, 1)
|
||||
|
||||
addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx)
|
||||
downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
gotConn <- true
|
||||
},
|
||||
})
|
||||
|
||||
downloadReq, err := http.NewRequestWithContext(
|
||||
httputils.NewAddrContext(&conn.NetAddr, c.ctx),
|
||||
downloadCtx,
|
||||
http.MethodGet,
|
||||
downloadURL.String(),
|
||||
nil,
|
||||
@@ -449,19 +487,36 @@ func (c *Client) DialStreamUp() (net.Conn, error) {
|
||||
}
|
||||
uploadReq.Host = c.cfg.Host
|
||||
|
||||
downloadResp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc := NewWaitReadCloser()
|
||||
|
||||
go func() {
|
||||
resp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc.CloseWithError(err)
|
||||
close(gotConn)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
wrc.CloseWithError(fmt.Errorf("xhttp stream-up download bad status: %s", resp.Status))
|
||||
return
|
||||
}
|
||||
wrc.Set(resp.Body)
|
||||
}()
|
||||
|
||||
if !<-gotConn {
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
var buf [0]byte
|
||||
_, err = wrc.Read(buf[:])
|
||||
return nil, err
|
||||
}
|
||||
if downloadResp.StatusCode != http.StatusOK {
|
||||
_ = downloadResp.Body.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
return nil, fmt.Errorf("xhttp stream-up download bad status: %s", downloadResp.Status)
|
||||
}
|
||||
|
||||
// Start upload after download TCP is connected, so the server has likely
|
||||
// already processed the GET and created the session. This preserves the
|
||||
// original ordering (download before upload) while still being async.
|
||||
go func() {
|
||||
resp, err := uploadTransport.RoundTrip(uploadReq)
|
||||
if err != nil {
|
||||
@@ -476,7 +531,7 @@ func (c *Client) DialStreamUp() (net.Conn, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
conn.reader = downloadResp.Body
|
||||
conn.reader = wrc
|
||||
conn.onClose = func() {
|
||||
_ = pr.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
@@ -518,8 +573,18 @@ func (c *Client) DialPacketUp() (net.Conn, error) {
|
||||
writer.writeCond = sync.Cond{L: &writer.writeMu}
|
||||
conn := &Conn{writer: writer}
|
||||
|
||||
// Async download: avoid blocking on CDN response header buffering
|
||||
gotConn := make(chan bool, 1)
|
||||
|
||||
addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx)
|
||||
downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
gotConn <- true
|
||||
},
|
||||
})
|
||||
|
||||
downloadReq, err := http.NewRequestWithContext(
|
||||
httputils.NewAddrContext(&conn.NetAddr, c.ctx),
|
||||
downloadCtx,
|
||||
http.MethodGet,
|
||||
downloadURL.String(),
|
||||
nil,
|
||||
@@ -536,20 +601,32 @@ func (c *Client) DialPacketUp() (net.Conn, error) {
|
||||
}
|
||||
downloadReq.Host = downloadCfg.Host
|
||||
|
||||
resp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc := NewWaitReadCloser()
|
||||
|
||||
go func() {
|
||||
resp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc.CloseWithError(err)
|
||||
close(gotConn)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
wrc.CloseWithError(fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status))
|
||||
return
|
||||
}
|
||||
wrc.Set(resp.Body)
|
||||
}()
|
||||
|
||||
if !<-gotConn {
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
var buf [0]byte
|
||||
_, err = wrc.Read(buf[:])
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
conn.reader = resp.Body
|
||||
conn.reader = wrc
|
||||
conn.onClose = func() {
|
||||
// uploadTransport already closed by writer
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
@@ -563,3 +640,59 @@ func newSessionID() string {
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// WaitReadCloser is an io.ReadCloser that blocks on Read() until the underlying
|
||||
// ReadCloser is provided via Set(). This enables returning a reader immediately
|
||||
// while the actual HTTP response body is obtained asynchronously in a goroutine,
|
||||
// breaking the synchronous RoundTrip deadlock with CDN header buffering.
|
||||
type WaitReadCloser struct {
|
||||
wait chan struct{}
|
||||
once sync.Once
|
||||
rc io.ReadCloser
|
||||
err error
|
||||
}
|
||||
|
||||
func NewWaitReadCloser() *WaitReadCloser {
|
||||
return &WaitReadCloser{wait: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Set provides the underlying ReadCloser and unblocks any pending Read calls.
|
||||
// Must be called at most once. If Close was already called, rc is closed to
|
||||
// prevent leaks.
|
||||
func (w *WaitReadCloser) Set(rc io.ReadCloser) {
|
||||
w.setup(rc, nil)
|
||||
}
|
||||
|
||||
// CloseWithError records an error and unblocks any pending Read calls.
|
||||
func (w *WaitReadCloser) CloseWithError(err error) {
|
||||
w.setup(nil, err)
|
||||
}
|
||||
|
||||
// setup sets the underlying ReadCloser and error.
|
||||
func (w *WaitReadCloser) setup(rc io.ReadCloser, err error) {
|
||||
w.once.Do(func() {
|
||||
w.rc = rc
|
||||
w.err = err
|
||||
close(w.wait)
|
||||
})
|
||||
if w.err != nil && rc != nil {
|
||||
_ = rc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WaitReadCloser) Read(b []byte) (int, error) {
|
||||
<-w.wait
|
||||
if w.rc == nil {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.rc.Read(b)
|
||||
}
|
||||
|
||||
func (w *WaitReadCloser) Close() error {
|
||||
w.setup(nil, net.ErrClosed)
|
||||
<-w.wait
|
||||
if w.rc != nil {
|
||||
return w.rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+33
-55
@@ -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,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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+58
-58
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -29,9 +29,8 @@ func testSingMux(t *testing.T, tunnel *TestTunnel, out outbound.ProxyAdapter) {
|
||||
protocol := protocol
|
||||
t.Run(protocol, func(t *testing.T) {
|
||||
singMuxOption := outbound.SingMuxOption{
|
||||
Enabled: true,
|
||||
Protocol: protocol,
|
||||
TCPTimeout: 30, // set a timeout value that is slightly larger than the default
|
||||
Enabled: true,
|
||||
Protocol: protocol,
|
||||
}
|
||||
out, err := outbound.NewSingMux(singMuxOption, ¬CloseProxyAdapter{out})
|
||||
if !assert.NoError(t, err) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/common/httputils"
|
||||
@@ -95,6 +96,7 @@ type httpConn struct {
|
||||
writer io.Writer
|
||||
flusher http.Flusher
|
||||
body io.ReadCloser
|
||||
setupOnce sync.Once
|
||||
created chan struct{}
|
||||
createErr error
|
||||
cancelFn func()
|
||||
@@ -105,19 +107,28 @@ type httpConn struct {
|
||||
deadline *time.Timer
|
||||
}
|
||||
|
||||
func (h *httpConn) setUp(body io.ReadCloser, err error) {
|
||||
h.body = body
|
||||
h.createErr = err
|
||||
close(h.created)
|
||||
func (h *httpConn) setup(body io.ReadCloser, err error) {
|
||||
h.setupOnce.Do(func() {
|
||||
h.body = body
|
||||
h.createErr = err
|
||||
close(h.created)
|
||||
})
|
||||
if h.createErr != nil && body != nil { // conn already closed before setup
|
||||
_ = body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *httpConn) waitCreated() error {
|
||||
<-h.created
|
||||
if h.body != nil {
|
||||
return nil
|
||||
}
|
||||
return h.createErr
|
||||
}
|
||||
|
||||
func (h *httpConn) Close() error {
|
||||
var errorArr []error
|
||||
h.setup(nil, net.ErrClosed)
|
||||
if closer, ok := h.writer.(io.Closer); ok {
|
||||
errorArr = append(errorArr, closer.Close())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/metacubex/mihomo/common/httputils"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
"github.com/metacubex/http/httptrace"
|
||||
"github.com/metacubex/quic-go"
|
||||
"github.com/metacubex/quic-go/http3"
|
||||
"github.com/metacubex/tls"
|
||||
@@ -344,7 +345,21 @@ func (c *Client) DialStreamOne() (net.Conn, error) {
|
||||
|
||||
conn := &Conn{writer: pw}
|
||||
|
||||
req, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, c.ctx), http.MethodPost, requestURL.String(), pr)
|
||||
// Use gotConn to detect when TCP connection is established, so we can
|
||||
// return the conn immediately without waiting for the HTTP response.
|
||||
// This breaks the deadlock where CDN buffers response headers until the
|
||||
// server sends body data, but the server waits for our request body,
|
||||
// which can't be sent because we haven't returned the conn yet.
|
||||
gotConn := make(chan bool, 1)
|
||||
|
||||
addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx)
|
||||
ctx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
gotConn <- true
|
||||
},
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), pr)
|
||||
if err != nil {
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
@@ -360,21 +375,34 @@ func (c *Client) DialStreamOne() (net.Conn, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
wrc := NewWaitReadCloser()
|
||||
|
||||
go func() {
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
wrc.CloseWithError(err)
|
||||
close(gotConn)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
_ = resp.Body.Close()
|
||||
wrc.CloseWithError(fmt.Errorf("xhttp stream-one bad status: %s", resp.Status))
|
||||
return
|
||||
}
|
||||
wrc.Set(resp.Body)
|
||||
}()
|
||||
|
||||
if !<-gotConn {
|
||||
// RoundTrip failed before TCP connected (e.g. DNS failure)
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
httputils.CloseTransport(transport)
|
||||
var buf [0]byte
|
||||
_, err = wrc.Read(buf[:])
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
_ = resp.Body.Close()
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
httputils.CloseTransport(transport)
|
||||
return nil, fmt.Errorf("xhttp stream-one bad status: %s", resp.Status)
|
||||
}
|
||||
conn.reader = resp.Body
|
||||
|
||||
conn.reader = wrc
|
||||
conn.onClose = func() {
|
||||
_ = pr.Close()
|
||||
httputils.CloseTransport(transport)
|
||||
@@ -411,8 +439,18 @@ func (c *Client) DialStreamUp() (net.Conn, error) {
|
||||
|
||||
sessionID := newSessionID()
|
||||
|
||||
// Async download: avoid blocking on CDN response header buffering
|
||||
gotConn := make(chan bool, 1)
|
||||
|
||||
addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx)
|
||||
downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
gotConn <- true
|
||||
},
|
||||
})
|
||||
|
||||
downloadReq, err := http.NewRequestWithContext(
|
||||
httputils.NewAddrContext(&conn.NetAddr, c.ctx),
|
||||
downloadCtx,
|
||||
http.MethodGet,
|
||||
downloadURL.String(),
|
||||
nil,
|
||||
@@ -449,19 +487,36 @@ func (c *Client) DialStreamUp() (net.Conn, error) {
|
||||
}
|
||||
uploadReq.Host = c.cfg.Host
|
||||
|
||||
downloadResp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc := NewWaitReadCloser()
|
||||
|
||||
go func() {
|
||||
resp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc.CloseWithError(err)
|
||||
close(gotConn)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
wrc.CloseWithError(fmt.Errorf("xhttp stream-up download bad status: %s", resp.Status))
|
||||
return
|
||||
}
|
||||
wrc.Set(resp.Body)
|
||||
}()
|
||||
|
||||
if !<-gotConn {
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
var buf [0]byte
|
||||
_, err = wrc.Read(buf[:])
|
||||
return nil, err
|
||||
}
|
||||
if downloadResp.StatusCode != http.StatusOK {
|
||||
_ = downloadResp.Body.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
return nil, fmt.Errorf("xhttp stream-up download bad status: %s", downloadResp.Status)
|
||||
}
|
||||
|
||||
// Start upload after download TCP is connected, so the server has likely
|
||||
// already processed the GET and created the session. This preserves the
|
||||
// original ordering (download before upload) while still being async.
|
||||
go func() {
|
||||
resp, err := uploadTransport.RoundTrip(uploadReq)
|
||||
if err != nil {
|
||||
@@ -476,7 +531,7 @@ func (c *Client) DialStreamUp() (net.Conn, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
conn.reader = downloadResp.Body
|
||||
conn.reader = wrc
|
||||
conn.onClose = func() {
|
||||
_ = pr.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
@@ -518,8 +573,18 @@ func (c *Client) DialPacketUp() (net.Conn, error) {
|
||||
writer.writeCond = sync.Cond{L: &writer.writeMu}
|
||||
conn := &Conn{writer: writer}
|
||||
|
||||
// Async download: avoid blocking on CDN response header buffering
|
||||
gotConn := make(chan bool, 1)
|
||||
|
||||
addrCtx := httputils.NewAddrContext(&conn.NetAddr, c.ctx)
|
||||
downloadCtx := httptrace.WithClientTrace(addrCtx, &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
gotConn <- true
|
||||
},
|
||||
})
|
||||
|
||||
downloadReq, err := http.NewRequestWithContext(
|
||||
httputils.NewAddrContext(&conn.NetAddr, c.ctx),
|
||||
downloadCtx,
|
||||
http.MethodGet,
|
||||
downloadURL.String(),
|
||||
nil,
|
||||
@@ -536,20 +601,32 @@ func (c *Client) DialPacketUp() (net.Conn, error) {
|
||||
}
|
||||
downloadReq.Host = downloadCfg.Host
|
||||
|
||||
resp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc := NewWaitReadCloser()
|
||||
|
||||
go func() {
|
||||
resp, err := downloadTransport.RoundTrip(downloadReq)
|
||||
if err != nil {
|
||||
wrc.CloseWithError(err)
|
||||
close(gotConn)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
wrc.CloseWithError(fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status))
|
||||
return
|
||||
}
|
||||
wrc.Set(resp.Body)
|
||||
}()
|
||||
|
||||
if !<-gotConn {
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
var buf [0]byte
|
||||
_, err = wrc.Read(buf[:])
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
httputils.CloseTransport(uploadTransport)
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
conn.reader = resp.Body
|
||||
conn.reader = wrc
|
||||
conn.onClose = func() {
|
||||
// uploadTransport already closed by writer
|
||||
httputils.CloseTransport(downloadTransport)
|
||||
@@ -563,3 +640,59 @@ func newSessionID() string {
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// WaitReadCloser is an io.ReadCloser that blocks on Read() until the underlying
|
||||
// ReadCloser is provided via Set(). This enables returning a reader immediately
|
||||
// while the actual HTTP response body is obtained asynchronously in a goroutine,
|
||||
// breaking the synchronous RoundTrip deadlock with CDN header buffering.
|
||||
type WaitReadCloser struct {
|
||||
wait chan struct{}
|
||||
once sync.Once
|
||||
rc io.ReadCloser
|
||||
err error
|
||||
}
|
||||
|
||||
func NewWaitReadCloser() *WaitReadCloser {
|
||||
return &WaitReadCloser{wait: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Set provides the underlying ReadCloser and unblocks any pending Read calls.
|
||||
// Must be called at most once. If Close was already called, rc is closed to
|
||||
// prevent leaks.
|
||||
func (w *WaitReadCloser) Set(rc io.ReadCloser) {
|
||||
w.setup(rc, nil)
|
||||
}
|
||||
|
||||
// CloseWithError records an error and unblocks any pending Read calls.
|
||||
func (w *WaitReadCloser) CloseWithError(err error) {
|
||||
w.setup(nil, err)
|
||||
}
|
||||
|
||||
// setup sets the underlying ReadCloser and error.
|
||||
func (w *WaitReadCloser) setup(rc io.ReadCloser, err error) {
|
||||
w.once.Do(func() {
|
||||
w.rc = rc
|
||||
w.err = err
|
||||
close(w.wait)
|
||||
})
|
||||
if w.err != nil && rc != nil {
|
||||
_ = rc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WaitReadCloser) Read(b []byte) (int, error) {
|
||||
<-w.wait
|
||||
if w.rc == nil {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.rc.Read(b)
|
||||
}
|
||||
|
||||
func (w *WaitReadCloser) Close() error {
|
||||
w.setup(nil, net.ErrClosed)
|
||||
<-w.wait
|
||||
if w.rc != nil {
|
||||
return w.rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -20,35 +20,47 @@ import (
|
||||
var errHTTP2Fallback = E.New("fallback to HTTP/1.1")
|
||||
|
||||
type http2FallbackTransport struct {
|
||||
h2Transport *http2.Transport
|
||||
h1Transport *http1Transport
|
||||
h2Fallback *atomic.Bool
|
||||
h2Transport *http2.Transport
|
||||
h1Transport *http1Transport
|
||||
fallbackAccess sync.RWMutex
|
||||
fallbackAuthority map[string]struct{}
|
||||
}
|
||||
|
||||
func newHTTP2FallbackTransport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTP2Options) (*http2FallbackTransport, error) {
|
||||
h1 := newHTTP1Transport(rawDialer, baseTLSConfig)
|
||||
var fallback atomic.Bool
|
||||
h2Transport, err := ConfigureHTTP2Transport(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h2Transport.DialTLSContext = func(ctx context.Context, network, addr string, _ *stdTLS.Config) (net.Conn, error) {
|
||||
conn, dialErr := dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS)
|
||||
if dialErr != nil {
|
||||
if errors.Is(dialErr, errHTTP2Fallback) {
|
||||
fallback.Store(true)
|
||||
}
|
||||
return nil, dialErr
|
||||
}
|
||||
return conn, nil
|
||||
return dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS)
|
||||
}
|
||||
return &http2FallbackTransport{
|
||||
h2Transport: h2Transport,
|
||||
h1Transport: h1,
|
||||
h2Fallback: &fallback,
|
||||
h2Transport: h2Transport,
|
||||
h1Transport: h1,
|
||||
fallbackAuthority: make(map[string]struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *http2FallbackTransport) isH2Fallback(authority string) bool {
|
||||
if authority == "" {
|
||||
return false
|
||||
}
|
||||
t.fallbackAccess.RLock()
|
||||
_, found := t.fallbackAuthority[authority]
|
||||
t.fallbackAccess.RUnlock()
|
||||
return found
|
||||
}
|
||||
|
||||
func (t *http2FallbackTransport) markH2Fallback(authority string) {
|
||||
if authority == "" {
|
||||
return
|
||||
}
|
||||
t.fallbackAccess.Lock()
|
||||
t.fallbackAuthority[authority] = struct{}{}
|
||||
t.fallbackAccess.Unlock()
|
||||
}
|
||||
|
||||
func (t *http2FallbackTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return t.roundTrip(request, true)
|
||||
}
|
||||
@@ -57,7 +69,8 @@ func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fall
|
||||
if request.URL.Scheme != "https" || requestRequiresHTTP1(request) {
|
||||
return t.h1Transport.RoundTrip(request)
|
||||
}
|
||||
if t.h2Fallback.Load() {
|
||||
authority := requestAuthority(request)
|
||||
if t.isH2Fallback(authority) {
|
||||
if !allowHTTP1Fallback {
|
||||
return nil, errHTTP2Fallback
|
||||
}
|
||||
@@ -70,6 +83,7 @@ func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fall
|
||||
if !errors.Is(err, errHTTP2Fallback) || !allowHTTP1Fallback {
|
||||
return nil, err
|
||||
}
|
||||
t.markH2Fallback(authority)
|
||||
return t.h1Transport.RoundTrip(cloneRequestForRetry(request))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (get_fn != NULL) {
|
||||
const char *protocol = get_fn(metadata);
|
||||
if (protocol != NULL) {
|
||||
return strdup(protocol);
|
||||
if (*copy_fn != NULL) {
|
||||
const char *value = (*copy_fn)(metadata);
|
||||
if (value == NULL) {
|
||||
*value_out = NULL;
|
||||
return true;
|
||||
}
|
||||
char *owned = strdup(value);
|
||||
free((void *)value);
|
||||
if (owned == NULL) {
|
||||
box_set_error_message(error_out, "apple TLS: out of memory");
|
||||
return false;
|
||||
}
|
||||
*value_out = owned;
|
||||
return true;
|
||||
}
|
||||
return NULL;
|
||||
if (*get_fn != NULL) {
|
||||
const char *value = (*get_fn)(metadata);
|
||||
if (value == NULL) {
|
||||
*value_out = NULL;
|
||||
return true;
|
||||
}
|
||||
char *owned = strdup(value);
|
||||
if (owned == NULL) {
|
||||
box_set_error_message(error_out, "apple TLS: out of memory");
|
||||
return false;
|
||||
}
|
||||
*value_out = owned;
|
||||
return true;
|
||||
}
|
||||
*value_out = NULL;
|
||||
return true;
|
||||
}
|
||||
|
||||
static char *box_apple_tls_metadata_copy_server_name(sec_protocol_metadata_t metadata) {
|
||||
static bool box_apple_tls_metadata_load_negotiated_protocol(sec_protocol_metadata_t metadata, char **value_out, char **error_out) {
|
||||
static box_sec_protocol_metadata_string_accessor_f copy_fn;
|
||||
static box_sec_protocol_metadata_string_accessor_f get_fn;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
copy_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_copy_server_name");
|
||||
get_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_get_server_name");
|
||||
});
|
||||
if (copy_fn != NULL) {
|
||||
return (char *)copy_fn(metadata);
|
||||
}
|
||||
if (get_fn != NULL) {
|
||||
const char *server_name = get_fn(metadata);
|
||||
if (server_name != NULL) {
|
||||
return strdup(server_name);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
return box_apple_tls_metadata_load_owned_string(
|
||||
metadata,
|
||||
"sec_protocol_metadata_copy_negotiated_protocol",
|
||||
"sec_protocol_metadata_get_negotiated_protocol",
|
||||
©_fn,
|
||||
&get_fn,
|
||||
&onceToken,
|
||||
value_out,
|
||||
error_out
|
||||
);
|
||||
}
|
||||
|
||||
static bool box_apple_tls_metadata_load_server_name(sec_protocol_metadata_t metadata, char **value_out, char **error_out) {
|
||||
static box_sec_protocol_metadata_string_accessor_f copy_fn;
|
||||
static box_sec_protocol_metadata_string_accessor_f get_fn;
|
||||
static dispatch_once_t onceToken;
|
||||
return box_apple_tls_metadata_load_owned_string(
|
||||
metadata,
|
||||
"sec_protocol_metadata_copy_server_name",
|
||||
"sec_protocol_metadata_get_server_name",
|
||||
©_fn,
|
||||
&get_fn,
|
||||
&onceToken,
|
||||
value_out,
|
||||
error_out
|
||||
);
|
||||
}
|
||||
|
||||
static NSArray<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);
|
||||
if (insecure) {
|
||||
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
|
||||
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
|
||||
if (client->ready_error == NULL) {
|
||||
char *local_error = NULL;
|
||||
if (!box_apple_tls_client_capture_state(client, metadata, &local_error)) {
|
||||
client->ready_error = local_error;
|
||||
}
|
||||
}
|
||||
if (insecure) {
|
||||
complete(true);
|
||||
}, box_apple_tls_client_queue(client));
|
||||
} else if (verifyDate != nil || anchors.count > 0 || anchor_only) {
|
||||
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
|
||||
complete(box_evaluate_trust(trust, anchors, anchor_only, verifyDate));
|
||||
}, box_apple_tls_client_queue(client));
|
||||
}
|
||||
return;
|
||||
}
|
||||
complete(box_evaluate_trust(trust, anchors, anchor_only, verifyDate));
|
||||
}, box_apple_tls_client_queue(client));
|
||||
}, NW_PARAMETERS_DEFAULT_CONFIGURATION);
|
||||
|
||||
nw_connection_t connection = box_apple_tls_create_connection(connected_socket, parameters);
|
||||
@@ -420,7 +466,11 @@ box_apple_tls_client_t *box_apple_tls_client_create(
|
||||
switch (state) {
|
||||
case nw_connection_state_ready:
|
||||
if (!atomic_load(&client->ready_done)) {
|
||||
atomic_store(&client->ready, box_apple_tls_state_load(connection, &client->state, &client->ready_error));
|
||||
bool state_loaded = client->state.version != 0;
|
||||
if (!state_loaded && client->ready_error == NULL) {
|
||||
box_set_error_message(&client->ready_error, "apple TLS: metadata unavailable");
|
||||
}
|
||||
atomic_store(&client->ready, state_loaded);
|
||||
atomic_store(&client->ready_done, true);
|
||||
dispatch_semaphore_signal(box_apple_tls_ready_semaphore(client));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# tls spoof
|
||||
|
||||
idea from https://github.com/therealaleph/sni-spoofing-rust
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,11 +21,20 @@ import (
|
||||
func requireRoot(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Geteuid() != 0 {
|
||||
t.Fatal("integration test requires root")
|
||||
t.Skip("integration test requires root; re-run with `go test -exec sudo`")
|
||||
}
|
||||
}
|
||||
|
||||
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
|
||||
t.Helper()
|
||||
return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle]
|
||||
}
|
||||
|
||||
// tcpdumpObserverMulti captures tcpdump output while do() executes and reports
|
||||
// which of the provided needles were observed in the raw ASCII dump. Use this
|
||||
// to assert that distinct payloads (e.g. fake vs real ClientHello) are both on
|
||||
// the wire.
|
||||
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||
defer cancel()
|
||||
@@ -62,16 +71,22 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do
|
||||
t.Fatal("tcpdump did not attach within 2s")
|
||||
}
|
||||
|
||||
var found atomic.Bool
|
||||
var access sync.Mutex
|
||||
found := make(map[string]bool, len(needles))
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
if strings.Contains(scanner.Text(), needle) {
|
||||
found.Store(true)
|
||||
line := scanner.Text()
|
||||
access.Lock()
|
||||
for _, needle := range needles {
|
||||
if !found[needle] && strings.Contains(line, needle) {
|
||||
found[needle] = true
|
||||
}
|
||||
}
|
||||
access.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -80,12 +95,26 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_ = cmd.Process.Signal(os.Interrupt)
|
||||
<-readerDone
|
||||
return found.Load()
|
||||
access.Lock()
|
||||
defer access.Unlock()
|
||||
result := make(map[string]bool, len(needles))
|
||||
for _, needle := range needles {
|
||||
result[needle] = found[needle]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) {
|
||||
return dialLocalEchoServerFamily(t, "tcp4", "127.0.0.1:0")
|
||||
}
|
||||
|
||||
func dialLocalEchoServerIPv6(t *testing.T) (client net.Conn, serverPort uint16) {
|
||||
return dialLocalEchoServerFamily(t, "tcp6", "[::1]:0")
|
||||
}
|
||||
|
||||
func dialLocalEchoServerFamily(t *testing.T, network, address string) (client net.Conn, serverPort uint16) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
listener, err := net.Listen(network, address)
|
||||
require.NoError(t, err)
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
@@ -97,7 +126,7 @@ func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) {
|
||||
close(accepted)
|
||||
}()
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
client, err = net.Dial("tcp4", addr.String())
|
||||
client, err = net.Dial(network, addr.String())
|
||||
require.NoError(t, err)
|
||||
server := <-accepted
|
||||
require.NotNil(t, server)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -34,14 +35,26 @@ const (
|
||||
darwinXtcpcbRcvNxtOffset = 80
|
||||
)
|
||||
|
||||
var darwinStructSize = sync.OnceValue(func() int {
|
||||
value, _ := syscall.Sysctl("kern.osrelease")
|
||||
major, _, _ := strings.Cut(value, ".")
|
||||
n, _ := strconv.ParseInt(major, 10, 64)
|
||||
if n >= 22 {
|
||||
return 408
|
||||
// darwinStructSize returns the size of xinpcb_n for the running Darwin kernel.
|
||||
// Darwin 22 (macOS 13 Ventura) grew the struct from 384 to 408 bytes; there is
|
||||
// no ABI-stable way to read it, so we key off the kernel version.
|
||||
var darwinStructSize = sync.OnceValues(func() (int, error) {
|
||||
value, err := syscall.Sysctl("kern.osrelease")
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "sysctl kern.osrelease")
|
||||
}
|
||||
return 384
|
||||
major, _, ok := strings.Cut(value, ".")
|
||||
if !ok {
|
||||
return 0, E.New("unexpected kern.osrelease format: ", value)
|
||||
}
|
||||
n, err := strconv.ParseInt(major, 10, 64)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "parse kern.osrelease major version: ", value)
|
||||
}
|
||||
if n >= 22 {
|
||||
return 408, nil
|
||||
}
|
||||
return 384, nil
|
||||
})
|
||||
|
||||
type darwinSpoofer struct {
|
||||
@@ -59,7 +72,7 @@ func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fd, sockaddr, err := openDarwinRawSocket(dst)
|
||||
fd, sockaddr, err := openDarwinRawSocket(src, dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -87,7 +100,10 @@ func readDarwinTCPSequence(src, dst netip.AddrPort) (uint32, uint32, error) {
|
||||
if err != nil {
|
||||
return 0, 0, E.Cause(err, "sysctl net.inet.tcp.pcblist_n")
|
||||
}
|
||||
structSize := darwinStructSize()
|
||||
structSize, err := darwinStructSize()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
itemSize := structSize + darwinTCPExtraSize
|
||||
for i := darwinXinpgenSize; i+itemSize <= len(buffer); i += itemSize {
|
||||
inpcb := buffer[i : i+darwinXsocketOffset]
|
||||
@@ -119,31 +135,50 @@ func readDarwinTCPSequence(src, dst netip.AddrPort) (uint32, uint32, error) {
|
||||
return 0, 0, E.New("tls_spoof: connection ", src, "->", dst, " not found in pcblist_n")
|
||||
}
|
||||
|
||||
func openDarwinRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
|
||||
if !dst.Addr().Is4() {
|
||||
// macOS does not expose IPV6_HDRINCL; raw AF_INET6 injection would
|
||||
// require either BPF link-layer writes or kernel-side IPv6 header
|
||||
// synthesis, neither of which is implemented here.
|
||||
return -1, nil, E.New("tls_spoof: IPv6 not supported on darwin")
|
||||
func openDarwinRawSocket(src, dst netip.AddrPort) (int, unix.Sockaddr, error) {
|
||||
if dst.Addr().Is4() {
|
||||
return openIPv4RawSocket(dst)
|
||||
}
|
||||
return openIPv4RawSocket(dst)
|
||||
// macOS does not accept IPV6_HDRINCL on AF_INET6 SOCK_RAW IPPROTO_TCP
|
||||
// sockets, so the kernel builds the IPv6 header itself. Bind to the real
|
||||
// connection's source address so in6_selectsrc returns it, and rely on
|
||||
// in6p_cksum defaulting to -1 so the user-supplied TCP checksum is
|
||||
// preserved (including deliberately corrupted ones).
|
||||
fd, err := unix.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_TCP)
|
||||
if err != nil {
|
||||
return -1, nil, E.Cause(err, "open AF_INET6 SOCK_RAW")
|
||||
}
|
||||
err = unix.Bind(fd, &unix.SockaddrInet6{Addr: src.Addr().As16()})
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return -1, nil, E.Cause(err, "bind AF_INET6 SOCK_RAW")
|
||||
}
|
||||
sockaddr := &unix.SockaddrInet6{Port: int(dst.Port()), Addr: dst.Addr().As16()}
|
||||
return fd, sockaddr, nil
|
||||
}
|
||||
|
||||
func (s *darwinSpoofer) Inject(payload []byte) error {
|
||||
if !s.src.Addr().Is4() {
|
||||
segment, err := buildSpoofTCPSegment(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = unix.Sendto(s.rawFD, segment, 0, s.rawSockAddr)
|
||||
if err != nil {
|
||||
return E.Cause(err, "sendto raw socket")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Darwin inherits the historical BSD quirk: with IP_HDRINCL the kernel
|
||||
// expects ip_len and ip_off in host byte order, not network byte order.
|
||||
// Apple's rip_output swaps them back before transmission. This does not
|
||||
// apply to IPv6.
|
||||
if s.src.Addr().Is4() {
|
||||
totalLen := binary.BigEndian.Uint16(frame[2:4])
|
||||
binary.NativeEndian.PutUint16(frame[2:4], totalLen)
|
||||
fragOff := binary.BigEndian.Uint16(frame[6:8])
|
||||
binary.NativeEndian.PutUint16(frame[6:8], fragOff)
|
||||
}
|
||||
// Apple's rip_output swaps them back before transmission.
|
||||
ip := header.IPv4(frame)
|
||||
ip.SetTotalLengthDarwinRaw(ip.TotalLength())
|
||||
ip.SetFlagsFragmentOffsetDarwinRaw(ip.Flags(), ip.FragmentOffset())
|
||||
err = unix.Sendto(s.rawFD, frame, 0, s.rawSockAddr)
|
||||
if err != nil {
|
||||
return E.Cause(err, "sendto raw socket")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,29 +51,34 @@ func NewSpoofer(conn net.Conn, method Method) (Spoofer, error) {
|
||||
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
spoofer Spoofer
|
||||
fakeSNI string
|
||||
injected bool
|
||||
spoofer Spoofer
|
||||
fakeHello []byte
|
||||
injected bool
|
||||
}
|
||||
|
||||
func NewConn(conn net.Conn, spoofer Spoofer, fakeSNI string) *Conn {
|
||||
return &Conn{
|
||||
Conn: conn,
|
||||
spoofer: spoofer,
|
||||
fakeSNI: fakeSNI,
|
||||
func NewConn(conn net.Conn, spoofer Spoofer, fakeSNI string) (*Conn, error) {
|
||||
fakeHello, err := buildFakeClientHello(fakeSNI)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "tls_spoof: build fake ClientHello")
|
||||
}
|
||||
return &Conn{
|
||||
Conn: conn,
|
||||
spoofer: spoofer,
|
||||
fakeHello: fakeHello,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Conn) Write(b []byte) (int, error) {
|
||||
func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
if c.injected {
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
defer c.spoofer.Close()
|
||||
fake, err := rewriteSNI(b, c.fakeSNI)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "tls_spoof: rewrite SNI")
|
||||
}
|
||||
err = c.spoofer.Inject(fake)
|
||||
defer func() {
|
||||
closeErr := c.spoofer.Close()
|
||||
if err == nil && closeErr != nil {
|
||||
err = E.Cause(closeErr, "tls_spoof: close spoofer")
|
||||
}
|
||||
}()
|
||||
err = c.spoofer.Inject(c.fakeHello)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "tls_spoof: inject")
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
|
||||
local json = require "luci.jsonc"
|
||||
|
||||
-- 读取值(keylist/vallist/group)
|
||||
local values = {}
|
||||
for i, key in pairs(self.keylist) do
|
||||
values[#values + 1] = {
|
||||
key = key,
|
||||
label = self.vallist[i] or key,
|
||||
group = self.group and self.group[i] or nil
|
||||
}
|
||||
end
|
||||
|
||||
-- 获取当前配置值
|
||||
local current_key = {}
|
||||
local cval = self:cfgvalue(section)
|
||||
if type(cval) == "table" then
|
||||
for _, v in ipairs(cval) do
|
||||
for item in string.gmatch(v, "[^,]+") do
|
||||
current_key[#current_key+1] = item
|
||||
end
|
||||
end
|
||||
elseif type(cval) == "string" then
|
||||
for item in string.gmatch(cval, "[^,]+") do
|
||||
current_key[#current_key+1] = item
|
||||
end
|
||||
end
|
||||
|
||||
-- 分离无组节点(ungrouped)与有组节点(grouped),并保持原有顺序
|
||||
local ungrouped = {}
|
||||
local groups = {}
|
||||
local group_order = {}
|
||||
for _, item in ipairs(values) do
|
||||
if not item.group or item.group == "" then
|
||||
table.insert(ungrouped, item)
|
||||
else
|
||||
local g = item.group
|
||||
if not groups[g] then
|
||||
groups[g] = {}
|
||||
table.insert(group_order, g)
|
||||
end
|
||||
table.insert(groups[g], item)
|
||||
end
|
||||
end
|
||||
|
||||
-- 打包数据给 JS
|
||||
local dropdown_data = {
|
||||
current_key = current_key,
|
||||
ungrouped = ungrouped,
|
||||
groups = groups,
|
||||
group_order = group_order,
|
||||
cbid = cbid,
|
||||
}
|
||||
%>
|
||||
<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)2025–2027
|
||||
-- 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 => ({
|
||||
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
||||
}[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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}}}" """;
|
||||
|
||||
@@ -1318,7 +1318,7 @@
|
||||
<value>Adresse sortante locale (SendThrough)</value>
|
||||
</data>
|
||||
<data name="TbSettingsSendThroughTip" xml:space="preserve">
|
||||
<value>Pour environnement multi-interfaces, veuillez saisir l’adresse 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 l’adresse 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 l’adresse 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);
|
||||
|
||||
@@ -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(';')
|
||||
|
||||
@@ -10,8 +10,8 @@ object AppConfig {
|
||||
/** Directory names used in the app's file system. */
|
||||
const val DIR_ASSETS = "assets"
|
||||
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
|
||||
/** Legacy configuration keys. */
|
||||
const val ANG_CONFIG = "ang_config"
|
||||
@@ -124,7 +124,8 @@ object AppConfig {
|
||||
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
||||
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
|
||||
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
const val IP_API_URL = "https://api.ip.sb/geoip"
|
||||
|
||||
/** DNS server addresses. */
|
||||
@@ -278,4 +279,9 @@ object AppConfig {
|
||||
"Chocolate4U/Iran-v2ray-rules"
|
||||
)
|
||||
|
||||
val BUILTIN_OUTBOUND_TAGS = setOf(
|
||||
TAG_PROXY,
|
||||
TAG_DIRECT,
|
||||
TAG_BLOCKED,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Tun2SocksControl {
|
||||
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
|
||||
*/
|
||||
fun startTun2Socks()
|
||||
|
||||
|
||||
/**
|
||||
* Stops the tun2socks process and cleans up resources.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user