DialEarly with a packet conn the caller made sets a flag that means quic-go does not own it: closing the transport only stops reading from the socket. Neither DNS transport closed it. On the QUIC one it was closed on a failed handshake and never on success, so every redial — idle timeout, retry error, engine reload — left a UDP socket for the life of the process. On the HTTP/3 one the library drives its own reconnects, so the leak compounds without anything in our code looking wrong. That is the same shape as v2rayquic's, where offerNew overwrote the raw conn on every reconnect without closing the previous one. Both are now owned by a watcher tied to the connection's own context, so the socket lives exactly as long as the connection does. This matters more than it did last week: the shipped resolvers are DoH, and DNS is intercepted by default now, so the whole network's query stream rides this path on a router with 512 MB. The same upstream commit fixes both halves. We had taken the v2ray half and not the DNS one — the third time this session a paired fix arrived half-applied, and the first of those cost a day of debugging. These two files are now byte-identical to upstream so a rebase cannot reopen it. Also from that family: websocket and httpupgrade leaked their conn on failed handshakes, and a QUIC stream's Close did not release a blocked write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
5.8 KiB
Go
212 lines
5.8 KiB
Go
package quic
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"github.com/sagernet/quic-go"
|
|
"github.com/sagernet/quic-go/http3"
|
|
"github.com/sagernet/sing-box/adapter"
|
|
"github.com/sagernet/sing-box/common/dialer"
|
|
"github.com/sagernet/sing-box/common/tls"
|
|
C "github.com/sagernet/sing-box/constant"
|
|
"github.com/sagernet/sing-box/dns"
|
|
"github.com/sagernet/sing-box/dns/transport"
|
|
"github.com/sagernet/sing-box/log"
|
|
"github.com/sagernet/sing-box/option"
|
|
"github.com/sagernet/sing/common"
|
|
"github.com/sagernet/sing/common/buf"
|
|
"github.com/sagernet/sing/common/bufio"
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
"github.com/sagernet/sing/common/logger"
|
|
M "github.com/sagernet/sing/common/metadata"
|
|
N "github.com/sagernet/sing/common/network"
|
|
sHTTP "github.com/sagernet/sing/protocol/http"
|
|
|
|
mDNS "github.com/miekg/dns"
|
|
)
|
|
|
|
var _ adapter.DNSTransport = (*HTTP3Transport)(nil)
|
|
|
|
func RegisterHTTP3Transport(registry *dns.TransportRegistry) {
|
|
dns.RegisterTransport[option.RemoteHTTPSDNSServerOptions](registry, C.DNSTypeHTTP3, NewHTTP3)
|
|
}
|
|
|
|
type HTTP3Transport struct {
|
|
dns.TransportAdapter
|
|
logger logger.ContextLogger
|
|
dialer N.Dialer
|
|
destination *url.URL
|
|
headers http.Header
|
|
serverAddr M.Socksaddr
|
|
tlsConfig *tls.STDConfig
|
|
transportAccess sync.Mutex
|
|
transport *http3.Transport
|
|
}
|
|
|
|
func NewHTTP3(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteHTTPSDNSServerOptions) (adapter.DNSTransport, error) {
|
|
transportDialer, err := dns.NewRemoteDialer(ctx, options.RemoteDNSServerOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tlsOptions := common.PtrValueOrDefault(options.TLS)
|
|
tlsOptions.Enabled = true
|
|
tlsConfig, err := tls.NewClient(ctx, logger, options.Server, tlsOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stdConfig, err := tlsConfig.STDConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
headers := options.Headers.Build()
|
|
host := headers.Get("Host")
|
|
if host != "" {
|
|
headers.Del("Host")
|
|
} else {
|
|
if tlsConfig.ServerName() != "" {
|
|
host = tlsConfig.ServerName()
|
|
} else {
|
|
host = options.Server
|
|
}
|
|
}
|
|
destinationURL := url.URL{
|
|
Scheme: "https",
|
|
Host: host,
|
|
}
|
|
if destinationURL.Host == "" {
|
|
destinationURL.Host = options.Server
|
|
}
|
|
if options.ServerPort != 0 && options.ServerPort != 443 {
|
|
destinationURL.Host = net.JoinHostPort(destinationURL.Host, strconv.Itoa(int(options.ServerPort)))
|
|
}
|
|
path := options.Path
|
|
if path == "" {
|
|
path = "/dns-query"
|
|
}
|
|
err = sHTTP.URLSetPath(&destinationURL, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
serverAddr := options.DNSServerAddressOptions.Build()
|
|
if serverAddr.Port == 0 {
|
|
serverAddr.Port = 443
|
|
}
|
|
if !serverAddr.IsValid() {
|
|
return nil, E.New("invalid server address: ", serverAddr)
|
|
}
|
|
t := &HTTP3Transport{
|
|
TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeHTTP3, tag, options.RemoteDNSServerOptions),
|
|
logger: logger,
|
|
dialer: transportDialer,
|
|
destination: &destinationURL,
|
|
headers: headers,
|
|
serverAddr: serverAddr,
|
|
tlsConfig: stdConfig,
|
|
}
|
|
t.transport = t.newTransport()
|
|
return t, nil
|
|
}
|
|
|
|
func (t *HTTP3Transport) newTransport() *http3.Transport {
|
|
return &http3.Transport{
|
|
Dial: func(ctx context.Context, addr string, tlsCfg *tls.STDConfig, cfg *quic.Config) (*quic.Conn, error) {
|
|
conn, dialErr := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr)
|
|
if dialErr != nil {
|
|
return nil, dialErr
|
|
}
|
|
quicConn, dialErr := quic.DialEarly(ctx, bufio.NewUnbindPacketConn(conn), conn.RemoteAddr(), tlsCfg, cfg)
|
|
if dialErr != nil {
|
|
conn.Close()
|
|
return nil, dialErr
|
|
}
|
|
// quic-go does not take ownership of the packet conn passed to
|
|
// DialEarly: when the connection ends it only stops reading.
|
|
go func() {
|
|
<-quicConn.Context().Done()
|
|
conn.Close()
|
|
}()
|
|
return quicConn, nil
|
|
},
|
|
TLSClientConfig: t.tlsConfig,
|
|
}
|
|
}
|
|
|
|
func (t *HTTP3Transport) Start(stage adapter.StartStage) error {
|
|
if stage != adapter.StartStateStart {
|
|
return nil
|
|
}
|
|
return dialer.InitializeDetour(t.dialer)
|
|
}
|
|
|
|
func (t *HTTP3Transport) Close() error {
|
|
t.transportAccess.Lock()
|
|
defer t.transportAccess.Unlock()
|
|
return t.transport.Close()
|
|
}
|
|
|
|
func (t *HTTP3Transport) Reset() {
|
|
t.transportAccess.Lock()
|
|
defer t.transportAccess.Unlock()
|
|
t.transport.Close()
|
|
t.transport = t.newTransport()
|
|
}
|
|
|
|
func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
|
|
exMessage := *message
|
|
exMessage.Id = 0
|
|
exMessage.Compress = true
|
|
requestBuffer := buf.NewSize(1 + message.Len())
|
|
rawMessage, err := exMessage.PackBuffer(requestBuffer.FreeBytes())
|
|
if err != nil {
|
|
requestBuffer.Release()
|
|
return nil, err
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage))
|
|
if err != nil {
|
|
requestBuffer.Release()
|
|
return nil, err
|
|
}
|
|
request.Header = t.headers.Clone()
|
|
request.Header.Set("Content-Type", transport.MimeType)
|
|
request.Header.Set("Accept", transport.MimeType)
|
|
t.transportAccess.Lock()
|
|
currentTransport := t.transport
|
|
t.transportAccess.Unlock()
|
|
response, err := currentTransport.RoundTrip(request)
|
|
requestBuffer.Release()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, E.New("unexpected status: ", response.Status)
|
|
}
|
|
var responseMessage mDNS.Msg
|
|
if response.ContentLength > 0 {
|
|
responseBuffer := buf.NewSize(int(response.ContentLength))
|
|
defer responseBuffer.Release()
|
|
_, err = responseBuffer.ReadFullFrom(response.Body, int(response.ContentLength))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = responseMessage.Unpack(responseBuffer.Bytes())
|
|
} else {
|
|
rawMessage, err = io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = responseMessage.Unpack(rawMessage)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &responseMessage, nil
|
|
}
|