Skip to content

The Packet Journey

This page traces the complete lifecycle of a connection through Xray-core, from the moment a client connects to when data reaches the remote server.

Overview

mermaid
flowchart TB
    Client([Client App]) -->|"Connect to<br/>listen port"| Listener

    subgraph Inbound["Inbound (app/proxyman/inbound)"]
        Listener["internet.Listener<br/>(TCP Hub)"]
        Worker["tcpWorker / udpWorker"]
        Proxy["proxy.Inbound.Process()<br/>(VLESS/VMess/Trojan/...)"]
    end

    subgraph Core["Core Pipeline"]
        Dispatcher["DefaultDispatcher.Dispatch()"]
        Sniff["Sniffer<br/>(HTTP/TLS/QUIC/FakeDNS)"]
        Router["Router.PickRoute()"]
    end

    subgraph Outbound["Outbound (app/proxyman/outbound)"]
        OHandler["outbound.Handler.Dispatch()"]
        Mux["Mux ClientManager<br/>(if mux enabled)"]
        OProxy["proxy.Outbound.Process()<br/>(VLESS/Freedom/...)"]
        Transport["internet.Dialer.Dial()<br/>(TCP/WS/gRPC/...)"]
    end

    Listener -->|stat.Connection| Worker
    Worker -->|"build ctx + call"| Proxy
    Proxy -->|"dispatcher.Dispatch(ctx, dest)"| Dispatcher
    Dispatcher --> Sniff
    Sniff --> Router
    Router -->|outbound tag| OHandler
    OHandler --> Mux
    Mux --> OProxy
    OProxy --> Transport
    Transport -->|"encrypted conn"| Server([Remote/Target])

Phase 1: Connection Acceptance

TCP Worker (app/proxyman/inbound/worker.go)

When a TCP connection arrives, the tcpWorker.callback() method fires:

go
func (w *tcpWorker) callback(conn stat.Connection) {
    ctx, cancel := context.WithCancel(w.ctx)
    sid := session.NewID()
    ctx = c.ContextWithID(ctx, sid)

    // Build outbound metadata
    outbounds := []*session.Outbound{{}}

    // For transparent proxy: get original destination
    if w.recvOrigDest {
        switch getTProxyType(w.stream) {
        case internet.SocketConfig_Redirect:
            dest, _ = tcp.GetOriginalDestination(conn)
        case internet.SocketConfig_TProxy:
            dest = net.DestinationFromAddr(conn.LocalAddr())
        }
        outbounds[0].Target = dest
    }
    ctx = session.ContextWithOutbounds(ctx, outbounds)

    // Attach inbound metadata
    ctx = session.ContextWithInbound(ctx, &session.Inbound{
        Source:  net.DestinationFromAddr(conn.RemoteAddr()),
        Gateway: net.TCPDestination(w.address, w.port),
        Tag:     w.tag,
        Conn:    conn,
    })

    // Attach sniffing config
    content := new(session.Content)
    content.SniffingRequest = ... // from config
    ctx = session.ContextWithContent(ctx, content)

    // Hand off to protocol handler
    w.proxy.Process(ctx, net.Network_TCP, conn, w.dispatcher)
}

Key context values set here:

  • session.Inbound — source address, inbound tag, raw connection
  • session.Outbound — target (populated for TProxy/redirect)
  • session.Content — sniffing configuration

UDP Worker

For UDP, the udpWorker handles packets differently:

  • Uses udp.Dispatcher to manage UDP "connections" (keyed by source)
  • Each unique source gets a virtual connection dispatched through the proxy
  • Timeout-based cleanup for idle UDP sessions

Phase 2: Protocol Processing (Inbound)

Each proxy protocol implements the proxy.Inbound interface:

go
type Inbound interface {
    Network() []net.Network
    Process(ctx context.Context, network net.Network,
        conn stat.Connection, dispatcher routing.Dispatcher) error
}

The protocol handler:

  1. Reads and decodes the protocol header from conn
  2. Extracts the target destination (address + port)
  3. Authenticates the user (if applicable)
  4. Calls dispatcher.Dispatch(ctx, destination) to get a pipe pair
  5. Copies data bidirectionally between conn and the pipe

Example: VLESS Inbound (simplified)

go
func (h *Handler) Process(ctx, network, connection, dispatch) error {
    // Read first bytes
    first := buf.FromBytes(make([]byte, buf.Size))
    first.ReadFrom(connection)

    // Decode VLESS header
    userSentID, request, requestAddons, err :=
        encoding.DecodeRequestHeader(first, reader, h.validator)

    // Set user in context
    ctx = session.ContextWithInbound(ctx, &session.Inbound{
        User: user,
        ...
    })

    // Dispatch to routing
    link, _ := dispatch.Dispatch(ctx, request.Destination())

    // Bidirectional copy
    // Upload: connection → link.Writer (to outbound)
    // Download: link.Reader → connection (to client)
    task.Run(ctx, requestDone, responseDone)
}

Phase 3: Dispatching

The DefaultDispatcher.Dispatch() is the central hub (app/dispatcher/default.go):

go
func (d *DefaultDispatcher) Dispatch(ctx, destination) (*transport.Link, error) {
    // Set target in outbound metadata
    ob.OriginalTarget = destination
    ob.Target = destination

    // Create pipe pair
    inbound, outbound := d.getLink(ctx)

    if sniffingRequest.Enabled {
        go func() {
            // Wrap reader with caching
            cReader := &cachedReader{reader: outbound.Reader}
            outbound.Reader = cReader

            // Sniff the first bytes
            result, err := sniffer(ctx, cReader, ...)

            // Override destination if sniffing matches
            if d.shouldOverride(ctx, result, ...) {
                destination.Address = net.ParseAddress(result.Domain())
                ob.Target = destination // or ob.RouteTarget for RouteOnly
            }

            d.routedDispatch(ctx, outbound, destination)
        }()
    } else {
        go d.routedDispatch(ctx, outbound, destination)
    }

    return inbound, nil  // returned to the inbound proxy
}

The Pipe Pair

getLink() creates two linked pipe pairs:

Client ←→ [InboundLink] ←→ Pipe ←→ [OutboundLink] ←→ Server

InboundLink:                    OutboundLink:
  Reader = downlinkReader         Reader = uplinkReader
  Writer = uplinkWriter           Writer = downlinkWriter

Client writes → uplinkWriter → uplinkReader → Server reads
Server writes → downlinkWriter → downlinkReader → Client reads

If stats are enabled, SizeStatWriter wrappers are inserted to count bytes.

Phase 4: Routing

routedDispatch() selects the outbound handler:

go
func (d *DefaultDispatcher) routedDispatch(ctx, link, destination) {
    // 1. Check forced outbound tag (from platform/API)
    if forcedTag := session.GetForcedOutboundTagFromContext(ctx); forcedTag != "" {
        handler = d.ohm.GetHandler(forcedTag)
    }
    // 2. Ask router to pick route
    else if route, err := d.router.PickRoute(routingCtx); err == nil {
        handler = d.ohm.GetHandler(route.GetOutboundTag())
    }
    // 3. Fall back to default outbound
    else {
        handler = d.ohm.GetDefaultHandler()
    }

    // Dispatch to the selected outbound
    handler.Dispatch(ctx, link)
}

The router evaluates rules sequentially (see Routing Engine).

Phase 5: Outbound Processing

Outbound Handler (app/proxyman/outbound/handler.go)

The outbound handler wrapper:

go
func (h *Handler) Dispatch(ctx, link) {
    // Check for mux
    if h.mux != nil && shouldUseMux(ctx) {
        h.mux.Dispatch(ctx, link)
        return
    }

    // Direct proxy processing
    h.proxy.Process(ctx, link, h)  // h implements internet.Dialer
}

Transport Dialing

When proxy.Process() calls dialer.Dial(ctx, dest):

  1. Look up stream settings for the outbound
  2. Select transport dialer (TCP/WS/gRPC/etc.)
  3. Dial the raw connection
  4. Apply security layer (TLS/REALITY/none)
  5. Return stat.Connection

Outbound Proxy Processing

The outbound proxy encodes its protocol and copies data:

go
func (h *Handler) Process(ctx, link, dialer) error {
    // Dial transport connection
    conn, _ := dialer.Dial(ctx, serverAddress)

    // Encode protocol header
    encoding.EncodeRequestHeader(conn, request, addons)

    // Bidirectional copy
    // Upload: link.Reader → conn (to server)
    // Download: conn → link.Writer (to client via pipe)
    task.Run(ctx, postRequest, getResponse)
}

Complete Sequence Diagram

mermaid
sequenceDiagram
    participant C as Client
    participant TW as tcpWorker
    participant PI as Proxy Inbound
    participant D as Dispatcher
    participant R as Router
    participant PO as Proxy Outbound
    participant T as Transport
    participant S as Remote Server

    C->>TW: TCP connect
    TW->>TW: Build session context
    TW->>PI: Process(ctx, conn, dispatcher)
    PI->>PI: Decode protocol header
    PI->>D: Dispatch(ctx, destination)
    D->>D: Create pipe pair
    D-->>PI: return inboundLink
    Note over D: async goroutine:
    D->>D: Sniff first bytes
    D->>R: PickRoute(ctx)
    R-->>D: outbound tag
    D->>PO: handler.Dispatch(ctx, outboundLink)
    PO->>T: dialer.Dial(ctx, server)
    T->>S: Transport connect + TLS
    T-->>PO: conn
    PO->>S: Encode header + payload

    par Upload (client → server)
        PI->>D: pipe.Write (client data)
        D->>PO: pipe.Read → conn.Write
    and Download (server → client)
        S->>PO: conn.Read
        PO->>D: pipe.Write (server data)
        D->>PI: pipe.Read → conn.Write
        PI->>C: response data
    end

Implementation Notes

When reimplementing, the critical pieces are:

  1. Session context — carries all metadata; must be threaded through every call
  2. Pipe pair — the async bridge between inbound and outbound; needs backpressure
  3. Sniffing — must happen on the first bytes before routing; cache consumed bytes
  4. Bidirectional copy — two goroutines (upload + download) with shared cancellation
  5. Activity timer — resets on each data transfer; triggers close on idle timeout

The task.Run(ctx, postRequest, task.OnSuccess(getResponse, task.Close(writer))) pattern is used everywhere: run upload first, then on success start download, close writer when download ends.

Technical analysis for re-implementation purposes.