نقل WebSocket
مقدمة
يغلف نقل WebSocket حركة مرور البروكسي داخل إطارات WebSocket قياسية، مما يجعلها تبدو كحركة مرور WebSocket شرعية لمراقبي الشبكة والأجهزة الوسيطة. يستخدم مكتبة gorilla/websocket لكل من العميل والخادم، ويدعم TLS مع بصمة uTLS، والبيانات المبكرة (0-RTT عبر رأس Sec-WebSocket-Protocol)، ومتصل متصفح اختياري للتشغيل في سياقات المتصفح.
تسجيل البروتوكول
مسجل باسم "websocket" (transport/internet/websocket/ws.go:8):
const protocolName = "websocket"- المتصل:
websocket/dialer.go:42-44 - المستمع:
websocket/hub.go:174-176 - الإعدادات:
websocket/config.go:33-37
تدفق الاتصال
نقطة الدخول
websocket.Dial (websocket/dialer.go:21-40):
func Dial(ctx context.Context, dest net.Destination,
streamSettings *internet.MemoryStreamConfig) (stat.Connection, error) {
if streamSettings.ProtocolSettings.(*Config).Ed > 0 {
// Early data mode: defer actual dial until first Write
conn = &delayDialConn{...}
} else {
conn, err = dialWebSocket(ctx, dest, streamSettings, nil)
}
return stat.Connection(conn), nil
}تفاصيل اتصال WebSocket
dialWebSocket (websocket/dialer.go:46-129) يبني websocket.Dialer وينفذ الترقية:
func dialWebSocket(ctx context.Context, dest net.Destination,
streamSettings *internet.MemoryStreamConfig, ed []byte) (net.Conn, error) {
wsSettings := streamSettings.ProtocolSettings.(*Config)
dialer := &websocket.Dialer{
NetDial: func(network, addr string) (net.Conn, error) {
return internet.DialSystem(ctx, dest, streamSettings.SocketSettings)
},
ReadBufferSize: 4 * 1024,
WriteBufferSize: 4 * 1024,
HandshakeTimeout: time.Second * 8,
}
// ...
}الجوانب الرئيسية:
- اتصال النظام: رد نداء
NetDialيفوض إلىinternet.DialSystem، مع تطبيق خيارات المقبس. - TLS: عند تكوين TLS، يصبح
protocolهو"wss"ويتم تعيينdialer.TLSClientConfig. - بصمة uTLS: عند تعيين بصمة، يتم تجاوز
dialer.NetDialTLSContextلاستخدامtls.UClientمعWebsocketHandshakeContext(يفرض ALPNhttp/1.1) (websocket/dialer.go:66-87). - بناء URI:
ws://أوwss://مع المضيف والمسار المُطبّع (websocket/dialer.go:90-94). - متصل المتصفح: إذا كان متاحًا، يتجاوز الاتصال العادي بالكامل (
websocket/dialer.go:96-103). - رأس Host: الأولوية: Host من الإعداد > ServerName من TLS > عنوان الوجهة (
websocket/dialer.go:106-113).
آلية البيانات المبكرة
عند تكوين Ed > 0، يؤدي أول استدعاء Write إلى تشغيل اتصال WebSocket الفعلي. يتم إرسال البايتات الأولية كبيانات مشفرة بـ base64 في رأس Sec-WebSocket-Protocol:
// websocket/dialer.go:114-117
if ed != nil {
header.Set("Sec-WebSocket-Protocol", base64.RawURLEncoding.EncodeToString(ed))
}بنية delayDialConn (websocket/dialer.go:131-184) تنفذ هذا الاتصال المؤجل:
sequenceDiagram
participant App as Application
participant DDC as delayDialConn
participant WS as WebSocket Server
App->>DDC: Write(data)
Note over DDC: First write triggers dial
alt len(data) <= Ed
DDC->>WS: WebSocket Upgrade + data in Sec-WebSocket-Protocol
DDC-->>App: len(data), nil
else len(data) > Ed
DDC->>WS: WebSocket Upgrade (no early data)
DDC->>WS: Write(data) via WebSocket frame
end
App->>DDC: Read(buf)
Note over DDC: Blocks until dialed channel signals
DDC->>WS: Read from WebSocketيستخرج الخادم البيانات المبكرة من رأس Sec-WebSocket-Protocol (websocket/hub.go:55-59):
if str := request.Header.Get("Sec-WebSocket-Protocol"); str != "" {
if ed, err := base64.RawURLEncoding.DecodeString(replacer.Replace(str)); err == nil && len(ed) > 0 {
extraReader = bytes.NewReader(ed)
responseHeader.Set("Sec-WebSocket-Protocol", str)
}
}تدفق الاستماع
إعداد خادم HTTP
ListenWS (websocket/hub.go:98-162) يعد خادم HTTP للتعامل مع ترقيات WebSocket:
func ListenWS(ctx context.Context, address net.Address, port net.Port,
streamSettings *internet.MemoryStreamConfig, addConn internet.ConnHandler) (internet.Listener, error) {
// Create net.Listener (TCP or Unix)
// Optionally wrap with TLS
l.server = http.Server{
Handler: &requestHandler{
host: wsSettings.Host,
path: wsSettings.GetNormalizedPath(),
ln: l,
},
ReadHeaderTimeout: time.Second * 4,
MaxHeaderBytes: 8192,
}
go l.server.Serve(l.listener)
return l, err
}معالجة الطلبات
requestHandler.ServeHTTP (websocket/hub.go:41-88) يتحقق من الاتصالات ويقوم بترقيتها:
- التحقق من المضيف: إذا تم تكوينه، يتحقق من
request.Hostمقابلconfig.Host(hub.go:42-46) - التحقق من المسار: مطابقة تامة لـ
request.URL.Pathمع المسار المُعدّ (hub.go:47-51) - استخراج البيانات المبكرة: فك تشفير رأس
Sec-WebSocket-Protocol(hub.go:55-59) - ترقية WebSocket: يستخدم
upgrader.Upgradeمن gorilla معCheckOriginمتساهل (hub.go:32-39،hub.go:62) - X-Forwarded-For: يستخرج عنوان IP الحقيقي للعميل من رؤوس إعادة التوجيه، مع احترام إعداد
TrustedXForwardedFor(hub.go:68-85)
المُرقّي العام (websocket/hub.go:32-39):
var upgrader = &websocket.Upgrader{
ReadBufferSize: 0,
WriteBufferSize: 0,
HandshakeTimeout: time.Second * 4,
CheckOrigin: func(r *http.Request) bool { return true },
}مغلف الاتصال
بنية connection (websocket/connection.go:19-22) تغلف *websocket.Conn:
type connection struct {
conn *websocket.Conn
reader io.Reader
remoteAddr net.Addr
}تنفيذ القراءة
القراءة موجهة بالرسائل (websocket/connection.go:45-59): يتم قراءة كل رسالة WebSocket بالكامل، وعند استنفاد رسالة واحدة، يتم جلب التالية عبر conn.NextReader():
func (c *connection) Read(b []byte) (int, error) {
for {
reader, err := c.getReader()
// ...
nBytes, err := reader.Read(b)
if errors.Cause(err) == io.EOF {
c.reader = nil // message exhausted, get next
continue
}
return nBytes, err
}
}يتم استهلاك القارئ الإضافي (البيانات المبكرة) أولاً إذا كان موجودًا.
تنفيذ الكتابة
الكتابة تنتج رسائل WebSocket ثنائية (websocket/connection.go:75-80):
func (c *connection) Write(b []byte) (int, error) {
if err := c.conn.WriteMessage(websocket.BinaryMessage, b); err != nil {
return 0, err
}
return len(b), nil
}نبضات القلب (Ping)
عند تكوين HeartbeatPeriod، يرسل روتين متزامن إطارات تحكم WebSocket Ping (websocket/connection.go:26-35):
func NewConnection(conn *websocket.Conn, remoteAddr net.Addr,
extraReader io.Reader, heartbeatPeriod uint32) *connection {
if heartbeatPeriod != 0 {
go func() {
for {
time.Sleep(time.Duration(heartbeatPeriod) * time.Second)
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Time{}); err != nil {
break
}
}
}()
}
// ...
}الإغلاق الرشيق
عند الإغلاق، يتم إرسال رسالة WebSocket CloseMessage قبل إغلاق الاتصال الأساسي (websocket/connection.go:89-101):
func (c *connection) Close() error {
c.conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(time.Second*5))
c.conn.Close()
// ...
}تنسيق السلك
يستخدم نقل WebSocket تأطير RFC 6455 القياسي:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <hash>
Sec-WebSocket-Protocol: <base64 early data> (if Ed > 0)
[Binary WebSocket frames containing proxy data]كل استدعاء Write ينتج رسالة WebSocket ثنائية واحدة. كل استدعاء Read يقرأ من الرسالة الحالية حتى EOF، ثم ينتقل إلى التالية.
خيارات الإعدادات
من websocket/config.go:
- Path: مسار WebSocket، مُطبّع بـ
/في البداية (config.go:11-20) - Host: قيمة رأس Host المتوقعة للتحقق من جانب الخادم
- Header: رؤوس HTTP إضافية (قاموس)، القيمة الافتراضية لـ
User-Agentهي وكيل Chrome (config.go:22-31) - Ed: الحد الأقصى لحجم البيانات المبكرة بالبايت. اضبطه على 0 للتعطيل.
- HeartbeatPeriod: الفاصل الزمني بالثواني لإطارات WebSocket Ping. 0 للتعطيل.
- AcceptProxyProtocol: تفعيل بروتوكول PROXY على المستمع.
ملاحظات التنفيذ
- gorilla/websocket: يُستخدم لكل من العميل والخادم. مُرقّي الخادم لديه مخازن مؤقتة بحجم صفر (
ReadBufferSize: 0, WriteBufferSize: 0) لتقليل استخدام الذاكرة، بينما يستخدم متصل العميل مخازن مؤقتة بحجم 4 كيلوبايت. - uTLS و WebSocket: عند استخدام بصمة uTLS، يفرض
WebsocketHandshakeContextاستخدامhttp/1.1في امتداد ALPN، لأن WebSocket يتطلب HTTP/1.1. - متصل المتصفح: عندما تعيد
browser_dialer.HasBrowserDialer()القيمة true، يتم إنشاء اتصالات WebSocket عبر واجهة WebSocket API الخاصة بالمتصفح بدلاً من مكدس شبكة Go. يُستخدم هذا لسيناريوهات النشر المعتمدة على المتصفح. - ترميز Base64: البيانات المبكرة تستخدم
RawURLEncoding(بدون حشو، أحرف آمنة لعناوين URL) للتوافق مع كل من V2Ray/V2Fly و Xray. - مُبدّل النصوص: جانب الخادم يستخدم
strings.NewReplacer("+", "-", "/", "_", "=", "")للتطبيع بين متغيرات base64 القياسية والآمنة لعناوين URL (hub.go:30). - العنوان البعيد: مغلف
connectionيتجاوزRemoteAddr()لدعم X-Forwarded-For، لذا قد يختلف العنوان المُبلغ عنه عن نظير TCP الفعلي.