diff --git a/proxy/shadowsocks_2022/relay_test.go b/proxy/shadowsocks_2022/relay_test.go index 096852ca6..ccf870d1b 100644 --- a/proxy/shadowsocks_2022/relay_test.go +++ b/proxy/shadowsocks_2022/relay_test.go @@ -374,3 +374,98 @@ func TestRelayTCPHandshakeForwarding(t *testing.T) { }) } } + +// SIP022 §3.2.4: a relay session is routed by client session ID, not by source address. +// After the client moves to a new source address, the inbound worker of the old address +// eventually expires and its context is canceled; the session must survive that. +func TestUDPSessionSurvivesOldSourceExpiry(t *testing.T) { + method, err := GetCipherMethod(MethodAES128GCM) + common.Must(err) + key := make([]byte, method.KeySaltLength) + _, _ = rand.Read(key) + + inbound, err := NewServer(newTestContext(), &ServerConfig{ + Method: MethodAES128GCM, + Key: base64.StdEncoding.EncodeToString(key), + }) + common.Must(err) + + var dispatchCount atomic.Int32 + var received atomic.Int32 + disp := &dummyDispatcher{ + onDispatch: func(ctx context.Context, d net.Destination) (*transport.Link, error) { + dispatchCount.Add(1) + linkR, linkW := gonet.Pipe() + // Like a real outbound, end the link when the dispatch context is canceled. + go func() { + <-ctx.Done() + linkW.Close() + linkR.Close() + }() + t.Cleanup(func() { + linkW.Close() + linkR.Close() + }) + return &transport.Link{ + Reader: buf.NewReader(linkR), + Writer: &customWriter{write: func(mb buf.MultiBuffer) error { + received.Add(int32(len(mb))) + buf.ReleaseMulti(mb) + return nil + }}, + }, nil + }, + } + + clientCodec, err := NewUDPPacketCodec(method, [][]byte{key}) + common.Must(err) + session, err := clientCodec.NewClientSession() + common.Must(err) + dest := net.UDPDestination(net.LocalHostIP, 443) + send := func(conn gonet.Conn) { + pkt, err := session.EncodePacket(dest, []byte("payload")) + common.Must(err) + defer pkt.Release() + if _, err := conn.Write(pkt.Bytes()); err != nil { + t.Fatal(err) + } + } + process := func(ctx context.Context) gonet.Conn { + clientConn, serverConn := gonet.Pipe() + t.Cleanup(func() { + clientConn.Close() + serverConn.Close() + }) + go func() { + _ = inbound.Process(ctx, net.Network_UDP, &dummyStatConn{Conn: serverConn}, disp) + }() + return clientConn + } + + ctxA, cancelA := context.WithCancel(newTestContext()) + defer cancelA() + connA := process(ctxA) + send(connA) + + // The client's source address changes. + ctxB, cancelB := context.WithCancel(newTestContext()) + defer cancelB() + connB := process(ctxB) + send(connB) + time.Sleep(50 * time.Millisecond) + + // The inbound worker of the old address expires. + cancelA() + connA.Close() + time.Sleep(50 * time.Millisecond) + + send(connB) + time.Sleep(50 * time.Millisecond) + + if n := dispatchCount.Load(); n != 1 { + t.Fatalf("session was dispatched %d times, want 1", n) + } + if n := received.Load(); n != 3 { + t.Fatalf("received %d packets, want 3", n) + } +} diff --git a/proxy/shadowsocks_2022/shadowsocks_2022.go b/proxy/shadowsocks_2022/shadowsocks_2022.go index 1556c330f..25c894f9d 100644 --- a/proxy/shadowsocks_2022/shadowsocks_2022.go +++ b/proxy/shadowsocks_2022/shadowsocks_2022.go @@ -63,7 +63,10 @@ func (s *ServerUDPSession) EnsureLink( s.currentConn.Store(conn) - sessCtx, cancel := context.WithCancel(ctx) + // The session is routed by client session ID and may outlive the inbound + // connection of the source address it started from (SIP022 §3.2.4), so its + // lifetime is governed by its own timer rather than by that connection. + sessCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) inbound := session.InboundFromContext(sessCtx) if inbound != nil && s.User != nil { inbound.User = s.User