mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-08 19:27:14 +00:00
be5ee3e0e1
Half-close. Both TCP relays -- RelayTCP into Xray's SOCKS5 inbound and relayTCPForward into a peer's tunnel address -- waited on a single `done` receive and then closed both sides. A client that finished sending and shut down its write side therefore had the connection torn down before the response came back. pipeBothWays now runs both directions to completion and propagates the half-close via CloseWrite (which *net.TCPConn and *gonet.TCPConn both implement), falling back to a full Close for anything that does not. Waiting for both directions reintroduces the risk the old single-receive was implicitly avoiding: a peer that vanishes mid-transfer would pin the pair forever. guardedReader bounds that, but as an idle window rather than a total one -- the deadline is re-armed on every read once armed -- so a slow transfer is never cut, while a silent peer is. Two minutes matches the idle window UDPRelay.pump and portForwardUDPIdleTimeout already use. UDP session retirement. pump's teardown deleted the map entry by key alone, so a session that lost a create race evicted whichever session currently held that source, orphaning a live flow. It now retires only its own entry, and Handle keeps the already-published session when it loses the race. The map is keyed on netip.AddrPort rather than src.String(), matching udpForwardListener next door and dropping one allocation per relayed datagram. SOCKS5 reply decoding. bytesReader had a value receiver, so each Read restarted at the head of the slice, and receive never advanced past a domain-form address because its switch only handled ATYP 0x01 and 0x04 -- a 0x03 reply decoded to a wrong source, port and payload. splitSocks5Addr replaces it: all three address forms, length-checked at every step, with the domain form accepting only a literal. Resolving there would have put a blocking DNS lookup on the receive path, and a datagram's own source is an address already. Unreachable against Xray's own inbound, which always answers with an IP, so this is a latent-bug fix rather than an observed one.
258 lines
8.2 KiB
Go
258 lines
8.2 KiB
Go
package amneziawgnet
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newDeadUDPSession builds a socks5UDPSession over real but already-closed
|
|
// sockets, so pump's receive fails immediately and its teardown runs at once.
|
|
func newDeadUDPSession(t *testing.T) *socks5UDPSession {
|
|
t.Helper()
|
|
peer, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
|
if err != nil {
|
|
t.Fatalf("listen udp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = peer.Close() })
|
|
udpConn, err := net.DialUDP("udp", nil, peer.LocalAddr().(*net.UDPAddr))
|
|
if err != nil {
|
|
t.Fatalf("dial udp: %v", err)
|
|
}
|
|
ctrlLn, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen tcp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ctrlLn.Close() })
|
|
ctrl, err := net.Dial("tcp", ctrlLn.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("dial tcp: %v", err)
|
|
}
|
|
_ = udpConn.Close()
|
|
return &socks5UDPSession{ctrl: ctrl, udpConn: udpConn}
|
|
}
|
|
|
|
// TestUDPRelayPumpOnlyRetiresItsOwnSession pins the flow that survives a
|
|
// duplicate associate: a losing pump must not evict the published session.
|
|
func TestUDPRelayPumpOnlyRetiresItsOwnSession(t *testing.T) {
|
|
relay := NewUDPRelay(SocksRelay{Addr: "127.0.0.1:1", Password: "x"}, nil)
|
|
src := netip.MustParseAddrPort("10.8.1.5:51820")
|
|
|
|
live := newDeadUDPSession(t)
|
|
superseded := newDeadUDPSession(t)
|
|
relay.sessions[src] = live
|
|
|
|
// Returns as soon as receive fails on the closed socket, so no wait is needed.
|
|
relay.pump(src, superseded)
|
|
|
|
got, ok := relay.sessions[src]
|
|
if !ok {
|
|
t.Fatal("live session was evicted: a retiring pump deleted src's entry regardless of which session held it")
|
|
}
|
|
if got != live {
|
|
t.Fatalf("sessions[%v] = %p, want the live session %p", src, got, live)
|
|
}
|
|
}
|
|
|
|
// TestUDPRelayCloseDropsEverySession keeps Close's contract explicit now that
|
|
// pump's teardown is conditional on still owning the key.
|
|
func TestUDPRelayCloseDropsEverySession(t *testing.T) {
|
|
relay := NewUDPRelay(SocksRelay{Addr: "127.0.0.1:1", Password: "x"}, nil)
|
|
for _, s := range []string{"10.8.1.5:51820", "10.8.1.6:2000"} {
|
|
relay.sessions[netip.MustParseAddrPort(s)] = newDeadUDPSession(t)
|
|
}
|
|
|
|
relay.Close()
|
|
|
|
if n := len(relay.sessions); n != 0 {
|
|
t.Fatalf("Close left %d sessions behind, want 0", n)
|
|
}
|
|
}
|
|
|
|
// tcpPair returns a connected pair of real loopback TCP conns; net.Pipe would
|
|
// not do, since these tests turn on CloseWrite, which it does not implement.
|
|
func tcpPair(t *testing.T) (client, server net.Conn) {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
type accepted struct {
|
|
conn net.Conn
|
|
err error
|
|
}
|
|
ch := make(chan accepted, 1)
|
|
go func() {
|
|
c, err := ln.Accept()
|
|
ch <- accepted{c, err}
|
|
}()
|
|
client, err = net.Dial("tcp", ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
got := <-ch
|
|
if got.err != nil {
|
|
t.Fatalf("accept: %v", got.err)
|
|
}
|
|
t.Cleanup(func() { _ = client.Close(); _ = got.conn.Close() })
|
|
return client, got.conn
|
|
}
|
|
|
|
// TestPipeBothWaysDeliversReplyAfterHalfClose is the half-close regression: a
|
|
// client that shuts down its write side must still receive the full response.
|
|
func TestPipeBothWaysDeliversReplyAfterHalfClose(t *testing.T) {
|
|
const request = "GET / HTTP/1.0\r\n\r\n"
|
|
const response = "the reply that arrives only after the request is complete"
|
|
|
|
client, a := tcpPair(t)
|
|
b, server := tcpPair(t)
|
|
go pipeBothWays(a, b)
|
|
|
|
if _, err := client.Write([]byte(request)); err != nil {
|
|
t.Fatalf("client write: %v", err)
|
|
}
|
|
// The half-close the old relay treated as "tear the whole pair down".
|
|
if err := client.(*net.TCPConn).CloseWrite(); err != nil {
|
|
t.Fatalf("client CloseWrite: %v", err)
|
|
}
|
|
|
|
_ = server.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
gotReq, err := io.ReadAll(server)
|
|
if err != nil {
|
|
t.Fatalf("server read: %v", err)
|
|
}
|
|
if string(gotReq) != request {
|
|
t.Fatalf("server got request %q, want %q", gotReq, request)
|
|
}
|
|
|
|
if _, err := server.Write([]byte(response)); err != nil {
|
|
t.Fatalf("server write: %v", err)
|
|
}
|
|
if err := server.(*net.TCPConn).CloseWrite(); err != nil {
|
|
t.Fatalf("server CloseWrite: %v", err)
|
|
}
|
|
|
|
_ = client.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
gotResp, err := io.ReadAll(client)
|
|
if err != nil {
|
|
t.Fatalf("client read: %v", err)
|
|
}
|
|
if string(gotResp) != response {
|
|
t.Fatalf("client got response %q, want %q: the reply was cut off by the half-close", gotResp, response)
|
|
}
|
|
}
|
|
|
|
// TestPipeBothWaysClosesWhenBothSidesFinish keeps the teardown contract: both
|
|
// directions ending must return, not hang on the idle bound.
|
|
func TestPipeBothWaysClosesWhenBothSidesFinish(t *testing.T) {
|
|
client, a := tcpPair(t)
|
|
b, server := tcpPair(t)
|
|
|
|
done := make(chan struct{})
|
|
go func() { defer close(done); pipeBothWays(a, b) }()
|
|
|
|
_ = client.(*net.TCPConn).CloseWrite()
|
|
_, _ = io.ReadAll(server)
|
|
_ = server.(*net.TCPConn).CloseWrite()
|
|
_, _ = io.ReadAll(client)
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("pipeBothWays did not return after both directions ended")
|
|
}
|
|
}
|
|
|
|
// liveUDPSession returns a session whose udpConn is connected to the returned
|
|
// peer, so a test can hand receive() one exact reply datagram.
|
|
func liveUDPSession(t *testing.T) (*socks5UDPSession, *net.UDPConn) {
|
|
t.Helper()
|
|
peer, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
|
if err != nil {
|
|
t.Fatalf("listen udp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = peer.Close() })
|
|
udpConn, err := net.DialUDP("udp", nil, peer.LocalAddr().(*net.UDPAddr))
|
|
if err != nil {
|
|
t.Fatalf("dial udp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = udpConn.Close() })
|
|
return &socks5UDPSession{udpConn: udpConn}, peer
|
|
}
|
|
|
|
// sendReply delivers one raw datagram to sess's socket.
|
|
func sendReply(t *testing.T, sess *socks5UDPSession, peer *net.UDPConn, datagram []byte) {
|
|
t.Helper()
|
|
if _, err := peer.WriteToUDP(datagram, sess.udpConn.LocalAddr().(*net.UDPAddr)); err != nil {
|
|
t.Fatalf("write reply: %v", err)
|
|
}
|
|
_ = sess.udpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
}
|
|
|
|
// TestSocks5ReceiveDecodesReplyAddressTypes covers all three ATYP forms; the
|
|
// domain form used to misread its own length byte and never skip the name.
|
|
func TestSocks5ReceiveDecodesReplyAddressTypes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
addrPart []byte
|
|
wantAddr string
|
|
}{
|
|
{"IPv4", []byte{0x01, 10, 0, 0, 7}, "10.0.0.7"},
|
|
{"IPv6", append([]byte{0x04}, netip.MustParseAddr("2001:db8::5").AsSlice()...), "2001:db8::5"},
|
|
{"domain holding a literal", append([]byte{0x03, 8}, []byte("10.0.0.9")...), "10.0.0.9"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sess, peer := liveUDPSession(t)
|
|
payload := []byte("the-actual-datagram-payload")
|
|
datagram := append([]byte{0x00, 0x00, 0x00}, tt.addrPart...)
|
|
datagram = append(datagram, 0x1f, 0x90) // port 8080
|
|
datagram = append(datagram, payload...)
|
|
sendReply(t, sess, peer, datagram)
|
|
|
|
buf := make([]byte, 4096)
|
|
from, got, err := sess.receive(buf)
|
|
if err != nil {
|
|
t.Fatalf("receive: %v", err)
|
|
}
|
|
want := netip.AddrPortFrom(netip.MustParseAddr(tt.wantAddr), 8080)
|
|
if from != want {
|
|
t.Errorf("source = %v, want %v", from, want)
|
|
}
|
|
if string(got) != string(payload) {
|
|
t.Errorf("payload = %q, want %q", got, payload)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSocks5ReceiveRejectsTruncatedReplies pins that a short datagram is an
|
|
// error, not a slice-bounds panic in the relay's own pump goroutine.
|
|
func TestSocks5ReceiveRejectsTruncatedReplies(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
datagram []byte
|
|
}{
|
|
{"header only, IPv4 announced", []byte{0x00, 0x00, 0x00, 0x01}},
|
|
{"IPv4 address cut short", []byte{0x00, 0x00, 0x00, 0x01, 10, 0}},
|
|
{"IPv6 address cut short", []byte{0x00, 0x00, 0x00, 0x04, 0x20, 0x01}},
|
|
{"domain length past the end", []byte{0x00, 0x00, 0x00, 0x03, 40, 'a', 'b'}},
|
|
{"address complete but port missing", []byte{0x00, 0x00, 0x00, 0x01, 10, 0, 0, 7}},
|
|
{"unsupported address type", []byte{0x00, 0x00, 0x00, 0x09, 1, 2, 3, 4, 0, 80}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sess, peer := liveUDPSession(t)
|
|
sendReply(t, sess, peer, tt.datagram)
|
|
buf := make([]byte, 4096)
|
|
if _, _, err := sess.receive(buf); err == nil {
|
|
t.Fatal("receive accepted a malformed datagram instead of returning an error")
|
|
}
|
|
})
|
|
}
|
|
}
|