fix(http3,doh3): stop releasing what is still being read
Two suspicions, both put to a test rather than to a reading. Both were real, and
neither was the leak the suspicion named — both are objects released while still
in use.
roundTripHTTP3Race ran both racers on one cancellable context and cancelled it
before returning the WINNER. quic-go and net/http reset a request's stream when
its context dies, so the caller got a response whose body stopped mid-read:
H3_REQUEST_CANCELLED (local) (read 2687 of 65536 bytes). That path is taken
whenever there is no cached HTTP/3 connection and the request is replayable —
the first request to every host, and every one after an idle close. Each racer
now has a context of its own; losers are cancelled where everything used to be,
and the winner's cancel travels with its body.
DoH3's Exchange packed the query into a POOLED buffer and released it the moment
RoundTrip returned. But http3 writes the request body on a goroutine of its own
and returns as soon as the response HEADERS arrive — the body is still being
read. With the window held open the query on the wire diverges from the query we
packed at exactly offset 8192, quic-go's copy-buffer size: everything past that
was the next pool user's memory, sent to the resolver. Not a slowdown — a data
race and a small memory-disclosure primitive. The buffer now goes back when the
transport closes the body, which http3 does on every path, and can do twice.
Both files diverge from upstream again, hours after 0a6689b29 made them
byte-identical on purpose. Upstream carries the second defect in
dns/transport/https.go too; that file is outside this audit and is named in D27
so the next person finds it instead of rediscovering it.
sing-quic moves v0.6.2-0.20260525051024 -> v0.6.4-0.20260709034545. quic.go is
byte-identical across the two, so this neither duplicates nor retires the
packet-conn ownership fix — quic-go still does not own the socket. What it does
carry is the other half of the family we took only half of: clientConn.Close in
tuic/, hysteria/ and hysteria2/ now sets a past write deadline, word for word
the fix v2rayquic already had. We ship tuic and hysteria2. Cost, measured:
+256 KiB exactly on the stripped aarch64 binary and six indirect modules for a
realm port-mapping path nothing we generate can reach.
Tests are mutation-checked: reverting each fix makes them fail, with the text
quoted above. The DoH3 test carries its own control — it first proves the pool
does hand a released buffer back and that poisoning it lands, because a clean
result from an instrument that cannot produce a dirty one proves nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
//go:build with_quic
|
||||
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/quic-go"
|
||||
"github.com/sagernet/quic-go/http3"
|
||||
sbTLS "github.com/sagernet/sing-box/common/tls"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
// raceProbePayload is large enough that it cannot ride along in the response
|
||||
// headers: the caller has to read the body off the QUIC stream AFTER
|
||||
// roundTripHTTP3Race has returned. That is the whole point of the test.
|
||||
const raceProbePayload = 64 * 1024
|
||||
|
||||
var _ N.Dialer = (*plainDialer)(nil)
|
||||
|
||||
type plainDialer struct{}
|
||||
|
||||
func (d *plainDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, destination.String())
|
||||
}
|
||||
|
||||
func (d *plainDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return net.ListenUDP("udp", nil)
|
||||
}
|
||||
|
||||
// splitDialer sends the HTTP/3 racer and the HTTP/2 racer to two different
|
||||
// listeners, so a test can decide which one of them wins without having to bind
|
||||
// a TCP and a UDP socket on the same port number.
|
||||
type splitDialer struct {
|
||||
udp M.Socksaddr
|
||||
tcp M.Socksaddr
|
||||
}
|
||||
|
||||
func (d *splitDialer) DialContext(ctx context.Context, network string, _ M.Socksaddr) (net.Conn, error) {
|
||||
destination := d.tcp
|
||||
if network == N.NetworkUDP {
|
||||
destination = d.udp
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, network, destination.String())
|
||||
}
|
||||
|
||||
func (d *splitDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return net.ListenUDP("udp", nil)
|
||||
}
|
||||
|
||||
func startH3Server(t *testing.T, handler http.Handler) M.Socksaddr {
|
||||
t.Helper()
|
||||
certificate, err := sbTLS.GenerateKeyPair(nil, nil, nil, "localhost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := quic.ListenAddrEarly("127.0.0.1:0", &stdTLS.Config{
|
||||
Certificates: []stdTLS.Certificate{*certificate},
|
||||
NextProtos: []string{http3.NextProtoH3},
|
||||
MinVersion: stdTLS.VersionTLS13,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &http3.Server{Handler: handler}
|
||||
go server.ServeListener(listener)
|
||||
t.Cleanup(func() {
|
||||
server.Close()
|
||||
listener.Close()
|
||||
})
|
||||
return M.ParseSocksaddr(listener.Addr().String())
|
||||
}
|
||||
|
||||
func newRaceProbeTransport(t *testing.T, serverAddr M.Socksaddr) (*http3FallbackTransport, string) {
|
||||
return newRaceProbeTransportWithDialer(t, &plainDialer{}, serverAddr)
|
||||
}
|
||||
|
||||
func newRaceProbeTransportWithDialer(t *testing.T, dialer N.Dialer, serverAddr M.Socksaddr) (*http3FallbackTransport, string) {
|
||||
t.Helper()
|
||||
baseTLSConfig, err := sbTLS.NewClient(context.Background(), logger.NOP(), "localhost", option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Insecure: true,
|
||||
ServerName: "localhost",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h2Fallback, err := newHTTP2FallbackTransport(dialer, baseTLSConfig, option.HTTP2Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inner, err := newHTTP3FallbackTransport(dialer, baseTLSConfig, h2Fallback, option.QUICOptions{}, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inner.Close() })
|
||||
return inner.(*http3FallbackTransport), "https://" + serverAddr.String() + "/probe"
|
||||
}
|
||||
|
||||
// TestHTTP3RaceWinnerBodyStaysReadable pins that the response handed back by the
|
||||
// HTTP/3 race is a LIVE response: its body must still be readable after
|
||||
// roundTripHTTP3Race returns. Cancelling the context the winner was issued on
|
||||
// resets its QUIC stream, so a "successful" round trip would hand the caller a
|
||||
// response it can never read.
|
||||
func TestHTTP3RaceWinnerBodyStaysReadable(t *testing.T) {
|
||||
payload := make([]byte, raceProbePayload)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
serverAddr := startH3Server(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(payload)
|
||||
}))
|
||||
transport, url := newRaceProbeTransport(t, serverAddr)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// No cached HTTP/3 connection yet and a bodyless GET is replayable, so this
|
||||
// takes the racing path.
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
t.Fatal("round trip: ", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.ProtoMajor != 3 {
|
||||
t.Fatalf("expected the HTTP/3 racer to win, got HTTP/%d.%d", response.ProtoMajor, response.ProtoMinor)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("the race winner's body died with the race: %v (read %d of %d bytes)", err, len(body), len(payload))
|
||||
}
|
||||
if len(body) != len(payload) {
|
||||
t.Fatalf("short body: got %d bytes, want %d", len(body), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTP3RaceFallbackWinnerBodyStaysReadableAndH3LoserIsCancelled covers the
|
||||
// other half of the race: the HTTP/2 fallback wins, so its body must survive the
|
||||
// race, and the HTTP/3 racer that lost must be torn down instead of being left
|
||||
// to run to completion on the caller's behalf.
|
||||
func TestHTTP3RaceFallbackWinnerBodyStaysReadableAndH3LoserIsCancelled(t *testing.T) {
|
||||
payload := make([]byte, raceProbePayload)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
|
||||
h3Started := make(chan struct{}, 1)
|
||||
h3Cancelled := make(chan struct{}, 1)
|
||||
// The HTTP/3 handler never answers, so the fallback wins on the timer.
|
||||
h3Addr := startH3Server(t, http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
||||
select {
|
||||
case h3Started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-request.Context().Done()
|
||||
select {
|
||||
case h3Cancelled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}))
|
||||
|
||||
h2Server := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(payload)
|
||||
}))
|
||||
h2Server.EnableHTTP2 = true
|
||||
h2Server.StartTLS()
|
||||
t.Cleanup(h2Server.Close)
|
||||
|
||||
transport, _ := newRaceProbeTransportWithDialer(t, &splitDialer{
|
||||
udp: h3Addr,
|
||||
tcp: M.ParseSocksaddr(h2Server.Listener.Addr().String()),
|
||||
}, h3Addr)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://localhost:443/probe", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
t.Fatal("round trip: ", err)
|
||||
}
|
||||
if response.ProtoMajor != 2 {
|
||||
t.Fatalf("expected the HTTP/2 fallback to win, got HTTP/%d.%d", response.ProtoMajor, response.ProtoMinor)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("the fallback winner's body died with the race: %v (read %d of %d bytes)", err, len(body), len(payload))
|
||||
}
|
||||
response.Body.Close()
|
||||
if len(body) != len(payload) {
|
||||
t.Fatalf("short body: got %d bytes, want %d", len(body), len(payload))
|
||||
}
|
||||
|
||||
select {
|
||||
case <-h3Started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the HTTP/3 racer never reached the server, the test proves nothing about cancelling it")
|
||||
}
|
||||
select {
|
||||
case <-h3Cancelled:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the losing HTTP/3 request was left running after the fallback won")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -168,32 +169,65 @@ func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Re
|
||||
return t.roundTripHTTP3Race(request, authority)
|
||||
}
|
||||
|
||||
// cancelOnBodyClose releases a racer's context when the caller is done with the
|
||||
// response it won. The race cannot release it on the way out: the body is read
|
||||
// after RoundTrip returns, and the context the request was issued on is what
|
||||
// keeps its stream alive.
|
||||
type cancelOnBodyClose struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
cancelOnce sync.Once
|
||||
}
|
||||
|
||||
func (b *cancelOnBodyClose) Close() error {
|
||||
err := b.ReadCloser.Close()
|
||||
b.cancelOnce.Do(b.cancel)
|
||||
return err
|
||||
}
|
||||
|
||||
func withCancelOnBodyClose(response *http.Response, cancel context.CancelFunc) *http.Response {
|
||||
if response == nil || response.Body == nil {
|
||||
cancel()
|
||||
return response
|
||||
}
|
||||
response.Body = &cancelOnBodyClose{ReadCloser: response.Body, cancel: cancel}
|
||||
return response
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, authority string) (*http.Response, error) {
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
defer cancel()
|
||||
type result struct {
|
||||
response *http.Response
|
||||
err error
|
||||
h3 bool
|
||||
}
|
||||
results := make(chan result, 2)
|
||||
startRoundTrip := func(request *http.Request, useH3 bool) {
|
||||
request = request.WithContext(ctx)
|
||||
var (
|
||||
response *http.Response
|
||||
err error
|
||||
)
|
||||
if useH3 {
|
||||
response, err = t.h3Transport.RoundTrip(request)
|
||||
} else {
|
||||
response, err = t.h2FallbackRoundTrip(request)
|
||||
}
|
||||
results <- result{response: response, err: err, h3: useH3}
|
||||
// Each racer runs on a context of its own. A context shared by both cannot be
|
||||
// cancelled when one of them wins: quic-go and net/http reset the winner's
|
||||
// stream on cancellation, so the caller would be handed a response whose body
|
||||
// stops mid-read with H3_REQUEST_CANCELLED. Only losers are cancelled here;
|
||||
// the winner's cancel travels with its body and fires on Close.
|
||||
startRoundTrip := func(useH3 bool) context.CancelFunc {
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
raceRequest := cloneRequestForRetry(request).WithContext(ctx)
|
||||
go func() {
|
||||
var (
|
||||
response *http.Response
|
||||
err error
|
||||
)
|
||||
if useH3 {
|
||||
response, err = t.h3Transport.RoundTrip(raceRequest)
|
||||
} else {
|
||||
response, err = t.h2FallbackRoundTrip(raceRequest)
|
||||
}
|
||||
results <- result{response: response, err: err, h3: useH3}
|
||||
}()
|
||||
return cancel
|
||||
}
|
||||
goroutines := 1
|
||||
received := 0
|
||||
var fallbackCancel context.CancelFunc
|
||||
h3Cancel := startRoundTrip(true)
|
||||
drainRemaining := func() {
|
||||
cancel()
|
||||
for range goroutines - received {
|
||||
go func() {
|
||||
loser := <-results
|
||||
@@ -203,7 +237,6 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
|
||||
}()
|
||||
}
|
||||
}
|
||||
go startRoundTrip(cloneRequestForRetry(request), true)
|
||||
timer := time.NewTimer(t.fallbackDelay)
|
||||
defer timer.Stop()
|
||||
var (
|
||||
@@ -215,20 +248,28 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
|
||||
case <-timer.C:
|
||||
if goroutines == 1 {
|
||||
goroutines++
|
||||
go startRoundTrip(cloneRequestForRetry(request), false)
|
||||
fallbackCancel = startRoundTrip(false)
|
||||
}
|
||||
case raceResult := <-results:
|
||||
received++
|
||||
if raceResult.err == nil {
|
||||
winnerCancel := fallbackCancel
|
||||
if raceResult.h3 {
|
||||
t.clearH3Broken(authority)
|
||||
winnerCancel = h3Cancel
|
||||
if fallbackCancel != nil {
|
||||
fallbackCancel()
|
||||
}
|
||||
} else {
|
||||
h3Cancel()
|
||||
}
|
||||
drainRemaining()
|
||||
return raceResult.response, nil
|
||||
return withCancelOnBodyClose(raceResult.response, winnerCancel), nil
|
||||
}
|
||||
if raceResult.h3 {
|
||||
t.markH3Broken(authority)
|
||||
h3Err = raceResult.err
|
||||
h3Cancel()
|
||||
if goroutines == 1 {
|
||||
goroutines++
|
||||
if !timer.Stop() {
|
||||
@@ -237,14 +278,21 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
|
||||
default:
|
||||
}
|
||||
}
|
||||
go startRoundTrip(cloneRequestForRetry(request), false)
|
||||
fallbackCancel = startRoundTrip(false)
|
||||
}
|
||||
} else {
|
||||
fallbackErr = raceResult.err
|
||||
if fallbackCancel != nil {
|
||||
fallbackCancel()
|
||||
}
|
||||
}
|
||||
if received < goroutines {
|
||||
continue
|
||||
}
|
||||
h3Cancel()
|
||||
if fallbackCancel != nil {
|
||||
fallbackCancel()
|
||||
}
|
||||
drainRemaining()
|
||||
switch {
|
||||
case h3Err != nil && fallbackErr != nil:
|
||||
|
||||
@@ -158,6 +158,29 @@ func (t *HTTP3Transport) Reset() {
|
||||
t.transport = t.newTransport()
|
||||
}
|
||||
|
||||
// pooledRequestBody hands the pooled buffer backing a query to the transport.
|
||||
//
|
||||
// quic-go writes the request body on a goroutine of its own: http3's doRequest
|
||||
// spawns it and returns as soon as the response HEADERS arrive, so the body is
|
||||
// still being read after RoundTrip has returned. Returning the buffer to the
|
||||
// pool at that point published live memory to the next caller while the query
|
||||
// was in flight — everything past the first 8 KiB (http3's copy buffer) went out
|
||||
// on the wire as whatever that caller had written there.
|
||||
//
|
||||
// http3.Transport closes the request body on every path — sendRequestBody defers
|
||||
// it on success, RoundTripOpt does it on every error — and it can do both for one
|
||||
// request, hence the Once.
|
||||
type pooledRequestBody struct {
|
||||
*bytes.Reader
|
||||
buffer *buf.Buffer
|
||||
release sync.Once
|
||||
}
|
||||
|
||||
func (b *pooledRequestBody) Close() error {
|
||||
b.release.Do(b.buffer.Release)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
|
||||
exMessage := *message
|
||||
exMessage.Id = 0
|
||||
@@ -168,11 +191,16 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
|
||||
requestBuffer.Release()
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage))
|
||||
requestBody := &pooledRequestBody{Reader: bytes.NewReader(rawMessage), buffer: requestBuffer}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), requestBody)
|
||||
if err != nil {
|
||||
requestBuffer.Release()
|
||||
requestBody.Close()
|
||||
return nil, err
|
||||
}
|
||||
// http.NewRequestWithContext only infers these for the body types it knows,
|
||||
// and pooledRequestBody is not one of them. GetBody stays nil on purpose: a
|
||||
// retry body would alias the same buffer past the first Close.
|
||||
request.ContentLength = int64(len(rawMessage))
|
||||
request.Header = t.headers.Clone()
|
||||
request.Header.Set("Content-Type", transport.MimeType)
|
||||
request.Header.Set("Accept", transport.MimeType)
|
||||
@@ -180,7 +208,6 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
|
||||
currentTransport := t.transport
|
||||
t.transportAccess.Unlock()
|
||||
response, err := currentTransport.RoundTrip(request)
|
||||
requestBuffer.Release()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package quic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/quic-go"
|
||||
"github.com/sagernet/quic-go/http3"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/dns"
|
||||
"github.com/sagernet/sing-box/dns/transport"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// The request body of a DoH3 query is backed by a POOLED buffer. quic-go sends
|
||||
// that body on a goroutine of its own which outlives RoundTrip (http3's
|
||||
// doRequest spawns it and returns as soon as the response HEADERS arrive), so
|
||||
// returning the buffer to the pool when RoundTrip returns hands live memory to
|
||||
// the next caller while the query is still being written to the wire. What goes
|
||||
// out then is whatever that next caller put there.
|
||||
//
|
||||
// The test forces the window that is normally microseconds wide to stay open:
|
||||
// the server pins a 2 KB stream receive window and answers before reading the
|
||||
// body, so the client is still blocked writing when Exchange returns.
|
||||
|
||||
const (
|
||||
// Big enough to need more than one 8 KiB read out of the request body
|
||||
// (http3's bodyCopyBufferSize), small enough to still come from the pool
|
||||
// (buf.MaxPooledBufferSize).
|
||||
paddedQuerySize = 20000
|
||||
// Pinned on the server so the client cannot write the whole body before the
|
||||
// response comes back.
|
||||
pinnedStreamWindow = 2048
|
||||
)
|
||||
|
||||
func paddedQuery(t *testing.T) (*mDNS.Msg, []byte) {
|
||||
t.Helper()
|
||||
message := new(mDNS.Msg)
|
||||
message.SetQuestion("example.com.", mDNS.TypeA)
|
||||
opt := new(mDNS.OPT)
|
||||
opt.Hdr.Name = "."
|
||||
opt.Hdr.Rrtype = mDNS.TypeOPT
|
||||
opt.Option = append(opt.Option, &mDNS.EDNS0_PADDING{Padding: make([]byte, paddedQuerySize)})
|
||||
message.Extra = append(message.Extra, opt)
|
||||
|
||||
// Exactly what HTTP3Transport.Exchange puts on the wire.
|
||||
onWire := *message
|
||||
onWire.Id = 0
|
||||
onWire.Compress = true
|
||||
expected, err := onWire.Pack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return message, expected
|
||||
}
|
||||
|
||||
// poisonPool takes buffers of one size class out of the pool and fills them with
|
||||
// a pattern no DNS message contains. The buffers are returned, not released: the
|
||||
// caller holds them so nothing can hand them back while the check runs.
|
||||
func poisonPool(size int, count int) []*buf.Buffer {
|
||||
poison := make([]*buf.Buffer, 0, count)
|
||||
for range count {
|
||||
buffer := buf.NewSize(size)
|
||||
poison = append(poison, buffer)
|
||||
free := buffer.FreeBytes()
|
||||
for i := range free {
|
||||
free[i] = 0xEE
|
||||
}
|
||||
}
|
||||
return poison
|
||||
}
|
||||
|
||||
func releaseAll(buffers []*buf.Buffer) {
|
||||
for _, buffer := range buffers {
|
||||
buffer.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// requirePoisonReachesReleasedBuffer is the CONTROL for the test below. A clean
|
||||
// result there means nothing unless this instrument is shown to be able to
|
||||
// produce a dirty one: it must be true that a buffer released while its bytes
|
||||
// are still referenced comes back out of the pool and gets overwritten. If this
|
||||
// stops holding — a different allocator, a pool that zeroes, a size class that
|
||||
// is not pooled at all — the test below would go green on broken code.
|
||||
//
|
||||
// Retried, because under -race sync.Pool.Put drops one object in four on
|
||||
// purpose. That same dice roll is why the check below is a 3-in-4 detector under
|
||||
// -race and a certainty without it; it can only make a broken build look clean,
|
||||
// never a clean build look broken.
|
||||
func requirePoisonReachesReleasedBuffer(t *testing.T, size int, pattern []byte) {
|
||||
t.Helper()
|
||||
for range 32 {
|
||||
control := buf.NewSize(size)
|
||||
free := control.FreeBytes()
|
||||
if len(free) < len(pattern) {
|
||||
t.Fatalf("control failed: a %d-byte buffer came back %d bytes long", size, len(free))
|
||||
}
|
||||
copy(free, pattern)
|
||||
alias := free[:len(pattern)]
|
||||
control.Release()
|
||||
|
||||
held := poisonPool(size, 8)
|
||||
poisoned := !bytes.Equal(alias, pattern)
|
||||
releaseAll(held)
|
||||
if poisoned {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("control failed: poisoning the pool never touched a released buffer, so a clean result below would prove nothing")
|
||||
}
|
||||
|
||||
// TestHTTP3ExchangeRequestBufferOutlivesRoundTrip proves that the query the
|
||||
// server receives is the query we asked to send, even when the pool is drained
|
||||
// the instant Exchange returns.
|
||||
func TestHTTP3ExchangeRequestBufferOutlivesRoundTrip(t *testing.T) {
|
||||
message, expected := paddedQuery(t)
|
||||
bufferSize := 1 + message.Len()
|
||||
requirePoisonReachesReleasedBuffer(t, bufferSize, expected)
|
||||
|
||||
drainGate := make(chan struct{})
|
||||
received := make(chan []byte, 1)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/dns-query", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Answer BEFORE reading the request body. A real resolver would not, but
|
||||
// any peer, middlebox or loss pattern that delays the body has the same
|
||||
// effect, and this makes the window deterministic.
|
||||
response := new(mDNS.Msg)
|
||||
response.SetReply(testQuery())
|
||||
rawResponse, err := response.Pack()
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", transport.MimeType)
|
||||
// Content-Length matters here: without it Exchange falls into io.ReadAll
|
||||
// and waits for the stream FIN, which this handler is about to withhold.
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(len(rawResponse)))
|
||||
writer.Write(rawResponse)
|
||||
writer.(http.Flusher).Flush()
|
||||
|
||||
<-drainGate
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
received <- body
|
||||
})
|
||||
listener, err := quic.ListenAddrEarly("127.0.0.1:0", testServerTLSConfig(t, []string{http3.NextProtoH3}), &quic.Config{
|
||||
InitialStreamReceiveWindow: pinnedStreamWindow,
|
||||
MaxStreamReceiveWindow: pinnedStreamWindow,
|
||||
InitialConnectionReceiveWindow: 1 << 16,
|
||||
MaxConnectionReceiveWindow: 1 << 16,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &http3.Server{Handler: mux}
|
||||
go server.ServeListener(listener)
|
||||
t.Cleanup(func() {
|
||||
server.Close()
|
||||
listener.Close()
|
||||
})
|
||||
|
||||
dialer := &trackingDialer{}
|
||||
t.Cleanup(dialer.closeAll)
|
||||
dnsTransport := &HTTP3Transport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3-buffer", nil),
|
||||
logger: logger.NOP(),
|
||||
dialer: dialer,
|
||||
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
|
||||
headers: http.Header{},
|
||||
serverAddr: M.ParseSocksaddr(listener.Addr().String()),
|
||||
tlsConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: "localhost",
|
||||
NextProtos: []string{http3.NextProtoH3},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
}
|
||||
dnsTransport.transport = dnsTransport.newTransport()
|
||||
t.Cleanup(func() { dnsTransport.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err = dnsTransport.Exchange(ctx, message); err != nil {
|
||||
t.Fatal("exchange: ", err)
|
||||
}
|
||||
|
||||
// Exchange has returned, the body is still in flight. Drain the size class it
|
||||
// came from, on this very goroutine, so a buffer released on the way out lands
|
||||
// in our hands and not somewhere harmless. The buffers are held until after
|
||||
// the comparison below.
|
||||
poison := poisonPool(bufferSize, 32)
|
||||
defer releaseAll(poison)
|
||||
|
||||
close(drainGate)
|
||||
var sent []byte
|
||||
select {
|
||||
case sent = <-received:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("the server never received the request body")
|
||||
}
|
||||
if !bytes.Equal(sent, expected) {
|
||||
firstDiff := -1
|
||||
for i := 0; i < len(sent) && i < len(expected); i++ {
|
||||
if sent[i] != expected[i] {
|
||||
firstDiff = i
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Fatalf("the query on the wire is not the query we packed: %d of %d bytes received, first difference at offset %d — "+
|
||||
"the pooled request buffer was reused while quic-go was still reading it", len(sent), len(expected), firstDiff)
|
||||
}
|
||||
}
|
||||
@@ -1202,3 +1202,79 @@ leave through an egress the operator explicitly named, under kernel routing and
|
||||
kernel NAT, instead of being dropped or silently leaking out the WAN; and with
|
||||
the option empty (the default) the plane renders byte-for-byte as before, with
|
||||
the D17 policy in sole charge.
|
||||
|
||||
## D27 — The `sing-quic` pin moves forward; two use-after-release defects in the QUIC/HTTP-3 client path
|
||||
Decided 2026-07-26, after an audit of `common/httpclient`, `dns/transport/quic`
|
||||
and `transport/v2rayquic`. Two suspicions were put to a test rather than to a
|
||||
reading. Both turned out to be real, and neither was the resource leak the
|
||||
suspicion named — both are objects released while still in use.
|
||||
|
||||
**1. The HTTP/3 race handed back a response nobody could read
|
||||
(`common/httpclient/http3_transport.go`).** `roundTripHTTP3Race` ran both racers
|
||||
on one `context.WithCancel` child and its `drainRemaining()` called `cancel()`
|
||||
before returning the WINNER. quic-go and net/http both reset a request's stream
|
||||
when its context is cancelled, so the caller received a `*http.Response` whose
|
||||
body died mid-read. Measured, not inferred:
|
||||
`H3_REQUEST_CANCELLED (local) (read 2687 of 65536 bytes)`. The path is taken
|
||||
whenever there is no cached HTTP/3 connection and the request is replayable —
|
||||
that is, the FIRST request to every host, plus every request after an idle
|
||||
close. Anything configured with `"version": 3` and no
|
||||
`disable_version_fallback` was affected: subscription fetches, remote rule-set
|
||||
downloads, URLTest probes.
|
||||
Fixed by giving each racer a context of its own. Losers are cancelled where the
|
||||
old code cancelled everything; the winner's `cancel` travels with its body and
|
||||
fires on `Close`. Pinned by
|
||||
`common/httpclient/http3_race_lx_test.go` — one test per winner, and the
|
||||
loser-is-torn-down assertion so the fix cannot be "stop cancelling" either.
|
||||
|
||||
**2. DoH3 sent the DNS server whatever the next caller put in a recycled buffer
|
||||
(`dns/transport/quic/http3.go`).** `Exchange` packed the query into a POOLED
|
||||
`buf.Buffer`, handed `bytes.NewReader` over it to the request, and called
|
||||
`requestBuffer.Release()` the moment `RoundTrip` returned. But http3's
|
||||
`doRequest` writes the request body on a goroutine of its own and returns as
|
||||
soon as the response HEADERS arrive — the body is still being read. The test
|
||||
holds that window open (a 2 KB server stream window, an answer written before
|
||||
the body is read) and shows the query on the wire diverging from the query we
|
||||
packed **at exactly offset 8192** — `bodyCopyBufferSize`, the amount quic-go had
|
||||
already copied out before the buffer went back to the pool. Everything past that
|
||||
was the next pool user's memory, sent to the resolver. That is a data race and a
|
||||
small memory-disclosure primitive, not a slowdown.
|
||||
Fixed by transferring ownership: the body is a `pooledRequestBody` whose `Close`
|
||||
releases the buffer. `http3.Transport` closes the request body on every path
|
||||
(`sendRequestBody` defers it, `RoundTripOpt` does it on error) and can do both
|
||||
for one request, hence the `sync.Once`. `GetBody` is deliberately left nil so no
|
||||
retry can alias a buffer that has already been released.
|
||||
Both files thereby DIVERGE from upstream again, six hours after `0a6689b29`
|
||||
made them byte-identical on purpose. That was the right call then and this is
|
||||
the right call now; upstream carries defect 2 in `dns/transport/https.go` as
|
||||
well (same shape, HTTP/1.1 and HTTP/2 write bodies asynchronously too) and that
|
||||
file was left alone — it is outside the audit's scope, and it is written down
|
||||
here so the next person finds it instead of rediscovering it.
|
||||
|
||||
**3. The pin moved: `sing-quic` v0.6.2-0.20260525051024 -> v0.6.4-0.20260709034545.**
|
||||
`quic.go` — `Dial`/`DialEarly`/`CreateTransport` — is byte-identical across the
|
||||
two, so the packet-conn ownership fix in `0a6689b29` is NOT duplicated by the
|
||||
bump and is not made redundant by it: quic-go still does not own the socket, and
|
||||
we still close it. What the newer module does carry is the OTHER half of the
|
||||
same family, and we had taken only our half:
|
||||
`clientConn.Close()` in `tuic/`, `hysteria/` and `hysteria2/` now sets a past
|
||||
write deadline after `Stream.Close()`, word for word the fix
|
||||
`transport/v2rayquic/stream.go` already had — quic-go's `Stream.Close` does not
|
||||
release a write blocked on flow control. We ship tuic and hysteria2, so on the
|
||||
old pin every such close could park a goroutine for the life of the process.
|
||||
It also brings a QUIC-connection-death watchdog and a handshake deadline to the
|
||||
hysteria clients.
|
||||
**The cost, measured, not estimated:** six new indirect modules (`libp2p/go-nat`
|
||||
and its UPnP/NAT-PMP/gopacket tail) for hysteria2's realm port mapping, and
|
||||
**+256 KiB exactly** on the stripped aarch64 `shaterd` (26 542 242 ->
|
||||
26 804 386 bytes, +0.99%), before UPX. The port-mapping path is unreachable from
|
||||
anything `shater/generate` emits — `realm` is only built when the JSON names it,
|
||||
and it never does — so the growth is dead weight, but it is small dead weight
|
||||
next to a goroutine leak on the two QUIC protocols we actually ship. `upstream/lx`
|
||||
is already on this pin, so keeping the old one would mean fighting every rebase.
|
||||
**Not verified here:** the tuic/hysteria2 close fix is read from the module diff,
|
||||
not exercised — those packages are outside this audit's file set and testing them
|
||||
needs a live tuic/hysteria2 server. `go test ./common/... ./dns/... ./transport/...
|
||||
./protocol/...` is green under the shipped tag set apart from
|
||||
`common/windivert`'s `TestIntegration*`, which want Windows SCM access and fail
|
||||
on any developer machine, bump or no bump.
|
||||
|
||||
@@ -46,7 +46,7 @@ require (
|
||||
github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d
|
||||
github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3
|
||||
github.com/sagernet/sing-mux v0.3.5
|
||||
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7
|
||||
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc
|
||||
github.com/sagernet/sing-shadowsocks v0.2.8
|
||||
github.com/sagernet/sing-shadowsocks2 v0.2.1
|
||||
github.com/sagernet/sing-shadowtls v0.2.1
|
||||
@@ -104,14 +104,20 @@ require (
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/huin/goupnp v1.2.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koron/go-ssdp v0.0.4 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect
|
||||
github.com/libp2p/go-netroute v0.2.1 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
|
||||
@@ -101,6 +101,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -109,10 +111,14 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8
|
||||
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY=
|
||||
github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
||||
@@ -122,6 +128,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
|
||||
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -138,6 +146,10 @@ github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0v
|
||||
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8=
|
||||
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8=
|
||||
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
|
||||
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco=
|
||||
@@ -264,8 +276,8 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6
|
||||
github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg=
|
||||
github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI=
|
||||
github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk=
|
||||
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7 h1:hFLPJ21uNZSbRnzhOKz4Zv0b4F93mpDorWyN93BeRcM=
|
||||
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7/go.mod h1:+oqD54aHel4ALKkp1hVXWCgLU/EjLojvm6AUzDfvj0I=
|
||||
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw=
|
||||
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
|
||||
github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
|
||||
@@ -360,6 +372,8 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
@@ -367,17 +381,24 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -389,6 +410,7 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
@@ -396,8 +418,10 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
Reference in New Issue
Block a user