Some checks failed
Android CI / build (push) Has been cancelled
sing-box 1.14 (пул DNS-соединений) отменяет dial-ctx сразу после dial(), а Xray подключается к серверу асинхронно на этом же ctx. Итог на VLESS: 5 мгновенных «operation was canceled», затем «closed pipe» через ~2 с; DNS через туннель падал/тормозил, YouTube не грузился. Линк Xray теперь живёт на WithoutCancel(ctx) и закрывается вместе с соединением или владельцем-outbound. Регрессионный тест TestZapretXrayConnectionsOutliveDialContext (падал до фикса).
239 lines
6.8 KiB
Go
239 lines
6.8 KiB
Go
package xraycore_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
box "github.com/sagernet/sing-box"
|
|
"github.com/sagernet/sing-box/adapter"
|
|
"github.com/sagernet/sing-box/include"
|
|
"github.com/sagernet/sing-box/option"
|
|
"github.com/sagernet/sing/common/json"
|
|
M "github.com/sagernet/sing/common/metadata"
|
|
)
|
|
|
|
func startBox(t *testing.T, text string) *box.Box {
|
|
t.Helper()
|
|
ctx := include.Context(context.Background())
|
|
var options option.Options
|
|
if err := json.UnmarshalContext(ctx, []byte(text), &options); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
instance, err := box.New(box.Options{Options: options, Context: ctx})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = instance.Close() })
|
|
if err = instance.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return instance
|
|
}
|
|
|
|
// startEchoFixture starts TCP/UDP echo servers and a VLESS server in front of
|
|
// them; it returns both echo endpoints and a client config whose "proxy"
|
|
// outbound is owned by the Xray module.
|
|
func startEchoFixture(t *testing.T) (net.Listener, net.PacketConn, string) {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = listener.Close() })
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() { defer conn.Close(); _, _ = io.Copy(conn, conn) }()
|
|
}
|
|
}()
|
|
udp, err := net.ListenPacket("udp4", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = udp.Close() })
|
|
go func() {
|
|
buffer := make([]byte, 2048)
|
|
for {
|
|
n, peer, err := udp.ReadFrom(buffer)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = udp.WriteTo(buffer[:n], peer)
|
|
}
|
|
}()
|
|
reserve, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
port := reserve.Addr().(*net.TCPAddr).Port
|
|
reserve.Close()
|
|
const uuid = "11111111-1111-1111-1111-111111111111"
|
|
startBox(t, fmt.Sprintf(`{
|
|
"log":{"disabled":true},
|
|
"inbounds":[{"type":"vless","listen":"127.0.0.1","listen_port":%d,
|
|
"users":[{"uuid":"%s"}]}],
|
|
"outbounds":[{"type":"direct","tag":"direct"}]
|
|
}`, port, uuid))
|
|
config := fmt.Sprintf(`{
|
|
"log":{"disabled":true},
|
|
"dns":{"servers":[{"type":"local","tag":"local"}]},
|
|
"outbounds":[{"type":"vless","tag":"proxy","server":"127.0.0.1","server_port":%d,
|
|
"uri":"vless://%s@127.0.0.1:%d?security=none&encryption=none&type=tcp"}]
|
|
}`, port, uuid, port)
|
|
return listener, udp, config
|
|
}
|
|
|
|
func proxyOutbound(t *testing.T, client *box.Box) adapter.Outbound {
|
|
t.Helper()
|
|
outbound, loaded := client.Outbound().Outbound("proxy")
|
|
if !loaded {
|
|
t.Fatal("outbound missing")
|
|
}
|
|
if fmt.Sprintf("%T", outbound) != "*xraycore.Outbound" {
|
|
t.Fatalf("wrong protocol owner: %T", outbound)
|
|
}
|
|
return outbound
|
|
}
|
|
|
|
// sing-box 1.14 DNS connection pools cancel the dial context as soon as dial()
|
|
// returns and keep the connection. Xray dispatches the upstream dial
|
|
// asynchronously, so the connection must not inherit the dial context's
|
|
// cancellation, or every pooled DNS exchange through the proxy fails.
|
|
func TestZapretXrayConnectionsOutliveDialContext(t *testing.T) {
|
|
listener, udp, config := startEchoFixture(t)
|
|
outbound := proxyOutbound(t, startBox(t, config))
|
|
|
|
echo := func(t *testing.T, conn net.Conn, payload string) {
|
|
t.Helper()
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
|
if _, err := conn.Write([]byte(payload)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := make([]byte, len(payload))
|
|
if _, err := io.ReadFull(conn, reply); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(reply) != payload {
|
|
t.Fatal("payload changed")
|
|
}
|
|
}
|
|
for _, network := range []string{"tcp", "udp"} {
|
|
t.Run(network, func(t *testing.T) {
|
|
target := listener.Addr().String()
|
|
if network == "udp" {
|
|
target = udp.LocalAddr().String()
|
|
}
|
|
dialCtx, cancel := context.WithCancel(context.Background())
|
|
conn, err := outbound.DialContext(dialCtx, network, M.ParseSocksaddr(target))
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
echo(t, conn, network+"-after-cancel")
|
|
})
|
|
}
|
|
t.Run("packet", func(t *testing.T) {
|
|
dialCtx, cancel := context.WithCancel(context.Background())
|
|
packet, err := outbound.ListenPacket(dialCtx, M.ParseSocksaddr(udp.LocalAddr().String()))
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer packet.Close()
|
|
_ = packet.SetDeadline(time.Now().Add(3 * time.Second))
|
|
if _, err = packet.WriteTo([]byte("packet-after-cancel"), udp.LocalAddr()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := make([]byte, 64)
|
|
n, _, err := packet.ReadFrom(data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data[:n]) != "packet-after-cancel" {
|
|
t.Fatal("payload changed")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestZapretXrayModuleCarriesTCPAndUDPAndIsolatesOwners(t *testing.T) {
|
|
listener, udp, config := startEchoFixture(t)
|
|
clients := []*box.Box{startBox(t, config), startBox(t, config)}
|
|
for index, client := range clients {
|
|
outbound := proxyOutbound(t, client)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
conn, err := outbound.DialContext(ctx, "tcp", M.ParseSocksaddr(listener.Addr().String()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
|
if _, err = conn.Write([]byte("tcp-echo")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := make([]byte, 8)
|
|
if _, err = io.ReadFull(conn, reply); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.Close()
|
|
if string(reply) != "tcp-echo" {
|
|
t.Fatal("TCP payload changed")
|
|
}
|
|
packet, err := outbound.ListenPacket(ctx, M.ParseSocksaddr(udp.LocalAddr().String()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = packet.WriteTo([]byte("udp-echo"), udp.LocalAddr()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
data := make([]byte, 32)
|
|
n, peer, err := packet.ReadFrom(data)
|
|
if err == nil && (string(data[:n]) != "udp-echo" || peer.String() != udp.LocalAddr().String()) {
|
|
err = fmt.Errorf("UDP payload or peer changed")
|
|
}
|
|
result <- err
|
|
}()
|
|
select {
|
|
case err = <-result:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
packet.Close()
|
|
t.Fatal("UDP response timed out")
|
|
}
|
|
packet.Close()
|
|
if index == 0 {
|
|
idlePacket, err := outbound.ListenPacket(ctx, M.ParseSocksaddr(udp.LocalAddr().String()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer idlePacket.Close()
|
|
if err = client.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
closed := make(chan error, 1)
|
|
go func() { _, _, err := idlePacket.ReadFrom(make([]byte, 32)); closed <- err }()
|
|
select {
|
|
case err = <-closed:
|
|
if err == nil {
|
|
t.Fatal("idle packet connection survived owner close")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("idle packet read blocked after owner close")
|
|
}
|
|
if _, err = outbound.DialContext(ctx, "tcp", M.ParseSocksaddr(listener.Addr().String())); err == nil {
|
|
t.Fatal("closed owner accepted new traffic")
|
|
}
|
|
}
|
|
}
|
|
}
|