Skip to content

نقل WebSocket

المصدر: transport/v2raywebsocket/client.go، transport/v2raywebsocket/server.go، transport/v2raywebsocket/conn.go، transport/v2raywebsocket/writer.go

نظرة عامة

يُنفِّذ نقل WebSocket نفق WebSocket المتوافق مع V2Ray باستخدام github.com/sagernet/ws (نسخة معدلة من gobwas/ws). يدعم إرسال البيانات المبكرة لإعداد اتصال 0-RTT، إما عبر ترميز مسار URL أو ترويسة HTTP مخصصة.

العميل

البناء

go
func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr,
    options option.V2RayWebsocketOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error)

منطق الإعداد الرئيسي:

  • إذا كان TLS مُعدًّا، يكون ALPN الافتراضي ["http/1.1"] ويُغلَّف طالب الاتصال بـ tls.NewDialer
  • مخطط URL هو ws (نص واضح) أو wss (TLS)
  • ترويسة Host من الخيارات تتجاوز مضيف URL
  • وكيل المستخدم الافتراضي هو "Go-http-client/1.1"

إنشاء الاتصال

go
func (c *Client) DialContext(ctx context.Context) (net.Conn, error) {
    if c.maxEarlyData <= 0 {
        conn, err := c.dialContext(ctx, &c.requestURL, c.headers)
        // ... return WebsocketConn directly
    } else {
        return &EarlyWebsocketConn{Client: c, ctx: ctx, create: make(chan struct{})}, nil
    }
}

بدون بيانات مبكرة، يُجري العميل ترقية WebSocket فورية عبر ws.Dialer.Upgrade(). مع البيانات المبكرة، يُرجع EarlyWebsocketConn كسولًا يؤجل الاتصال الفعلي حتى أول كتابة.

ترقية WebSocket

go
func (c *Client) dialContext(ctx context.Context, requestURL *url.URL, headers http.Header) (*WebsocketConn, error) {
    conn, err := c.dialer.DialContext(ctx, N.NetworkTCP, c.serverAddr)
    // ...
    deadlineConn.SetDeadline(time.Now().Add(C.TCPTimeout))
    reader, _, err := ws.Dialer{Header: ws.HandshakeHeaderHTTP(headers), Protocols: protocols}.Upgrade(deadlineConn, requestURL)
    deadlineConn.SetDeadline(time.Time{})
    // If reader has buffered data, wrap conn with CachedConn
    return NewConn(conn, nil, ws.StateClientSide), nil
}

تُستخرج ترويسة Sec-WebSocket-Protocol إلى حقل Protocols للتفاوض السليم على البروتوكول الفرعي لـ WebSocket. يُعيَّن موعد نهائي TCPTimeout أثناء المصافحة، ثم يُمسح.

البيانات المبكرة

تسمح البيانات المبكرة بتضمين الحمولة الأولى في مصافحة WebSocket، محققة 0-RTT:

وضعان

  1. وضع مسار URL (earlyDataHeaderName == ""): تُلحق البيانات المبكرة المرمزة بـ base64 بمسار URL
  2. وضع الترويسة المخصصة (earlyDataHeaderName != ""): توضع البيانات المبكرة المرمزة بـ base64 في ترويسة HTTP المحددة (عادة Sec-WebSocket-Protocol)

EarlyWebsocketConn

يستخدم هذا الهيكل التهيئة الكسولة مع مؤشر ذري وقناة للتزامن:

go
type EarlyWebsocketConn struct {
    *Client
    ctx    context.Context
    conn   atomic.Pointer[WebsocketConn]
    access sync.Mutex
    create chan struct{}
    err    error
}

الكتابة (تُحفِّز الاتصال):

go
func (c *EarlyWebsocketConn) Write(b []byte) (n int, err error) {
    conn := c.conn.Load()
    if conn != nil {
        return conn.Write(b)  // Fast path: already connected
    }
    c.access.Lock()
    defer c.access.Unlock()
    // ... double-check conn after acquiring lock
    err = c.writeRequest(b)   // Establish connection with early data
    c.err = err
    close(c.create)           // Signal readers
    // ...
}

القراءة (تنتظر حتى يوجد الاتصال):

go
func (c *EarlyWebsocketConn) Read(b []byte) (n int, err error) {
    conn := c.conn.Load()
    if conn == nil {
        <-c.create           // Wait for Write to establish connection
        if c.err != nil {
            return 0, c.err
        }
        conn = c.conn.Load()
    }
    return conn.Read(b)
}

تقسم دالة writeRequest البيانات عند حدود maxEarlyData: البيانات ضمن الحد تذهب إلى المصافحة، وأي فائض يُكتب كإطار WebSocket عادي بعد الاتصال.

الخادم

معالجة الطلبات

يتحقق الخادم من طلب HTTP الوارد ويستخرج البيانات المبكرة:

go
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
    // Path validation
    // Early data extraction (URL path or custom header)
    earlyData, err = base64.RawURLEncoding.DecodeString(earlyDataStr)
    // WebSocket upgrade
    wsConn, _, _, err := ws.UpgradeHTTP(request, writer)
    conn = NewConn(wsConn, source, ws.StateServerSide)
    if len(earlyData) > 0 {
        conn = bufio.NewCachedConn(conn, buf.As(earlyData))
    }
    s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, source, M.Socksaddr{}, nil)
}

عندما يكون earlyDataHeaderName فارغًا و maxEarlyData > 0، يقبل الخادم أي مسار يبدأ بالمسار المُعدّ ويعامل اللاحقة كبيانات مبكرة مرمزة بـ base64.

WebsocketConn

يُغلِّف net.Conn خامًا مع قراءة/كتابة إطارات WebSocket:

القراءة

go
func (c *WebsocketConn) Read(b []byte) (n int, err error) {
    for {
        n, err = c.reader.Read(b)
        if n > 0 { return }
        // Get next frame
        header, err = c.reader.NextFrame()
        if header.OpCode.IsControl() {
            // Handle control frames (ping/pong/close)
            c.controlHandler(header, c.reader)
            continue
        }
        if header.OpCode&ws.OpBinary == 0 {
            c.reader.Discard()  // Skip non-binary frames
            continue
        }
    }
}

تُعالَج فقط الإطارات الثنائية؛ إطارات النص تُتجاهل بصمت. إطارات التحكم (ping، pong، إغلاق) تُعالَج مباشرة عبر استدعاء controlHandler.

الإغلاق

go
func (c *WebsocketConn) Close() error {
    c.Conn.SetWriteDeadline(time.Now().Add(C.TCPTimeout))
    frame := ws.NewCloseFrame(ws.NewCloseFrameBody(ws.StatusNormalClosure, ""))
    if c.state == ws.StateClientSide {
        frame = ws.MaskFrameInPlace(frame)
    }
    ws.WriteFrame(c.Conn, frame)
    c.Conn.Close()
    return nil
}

إطارات الإغلاق من جانب العميل تُقنَّع وفقًا لمواصفات WebSocket RFC.

الكاتب المُحسَّن

يوفر هيكل Writer كتابة إطارات بدون نسخ باستخدام المساحة الاحتياطية للمخزن المؤقت:

go
func (w *Writer) WriteBuffer(buffer *buf.Buffer) error {
    // Calculate payload bit length (1, 3, or 9 bytes)
    // Calculate header length (1 + payloadBitLength + optional 4 mask bytes)
    header := buffer.ExtendHeader(headerLen)
    header[0] = byte(ws.OpBinary) | 0x80  // FIN + Binary
    // Encode payload length
    if !w.isServer {
        // Client side: generate and apply mask
        maskKey := rand.Uint32()
        ws.Cipher(data, [4]byte(header[1+payloadBitLength:]), 0)
    }
    return w.writer.WriteBuffer(buffer)
}

func (w *Writer) FrontHeadroom() int {
    return 14  // Maximum header size (2 + 8 + 4)
}

تُرجع دالة FrontHeadroom() القيمة 14 بايت (الحد الأقصى لترويسة WebSocket: 2 أساسية + 8 طول ممتد + 4 مفتاح قناع)، مما يسمح لتخصيصات المخزن المؤقت في المنبع بحجز مساحة للترويسة، متجنبة نسخ البيانات.

الإعدادات

json
{
  "transport": {
    "type": "ws",
    "path": "/tunnel",
    "headers": {
      "Host": "cdn.example.com"
    },
    "max_early_data": 2048,
    "early_data_header_name": "Sec-WebSocket-Protocol"
  }
}
الحقلالوصف
pathمسار URL لنقطة نهاية WebSocket (يُضاف تلقائيًا / في البداية)
headersترويسات HTTP إضافية؛ Host تتجاوز مضيف URL
max_early_dataالحد الأقصى للبايتات المُضمَّنة في المصافحة (0 = معطل)
early_data_header_nameاسم الترويسة للبيانات المبكرة (فارغ = استخدام مسار URL)