すべての記事

One Port, Five Proxy Protocols: Inside MonoProxy’s Relay Architecture

How MonoProxy classifies HTTP, CONNECT and SOCKS on one listener, then preserves ordering, backpressure, half-close semantics and security boundaries.

proxynetworkingiOSSOCKSHTTP
One Port, Five Proxy Protocols: Inside MonoProxy’s Relay Architecture

A proxy port sees bytes before it sees protocols.

That sounds obvious, but it is where a shared-port proxy becomes difficult. A new TCP connection does not arrive with a label saying "SOCKS5" or "HTTPS CONNECT." The first read may contain one byte, an entire handshake, or a handshake followed by application data. If the server consumes too much while trying to identify the protocol, those bytes are gone from the handler that actually needs them. If it decides too early, a fragmented request is misclassified.

MonoProxy had to solve that problem while keeping the listener small enough to run predictably on an iPhone. The result is a single local endpoint for HTTP, HTTPS CONNECT, SOCKS4, SOCKS4a, and SOCKS5, with one relay engine behind the protocol-specific handshakes.

The port is shared; the state is not

The listener accepts TCP connections through Apple's Network framework and sends every connection to one serialized server queue. The first receive is deliberately conservative:

Flow diagram Preparing diagram
View diagram source
flowchart LR
    A[New TCP connection] --> B{First byte}
    B -->|0x05| C[SOCKS5 negotiation]
    B -->|0x04| D[SOCKS4 or SOCKS4a request]
    B -->|Anything else| E[Accumulate HTTP header]
    E --> F{Request method}
    F -->|CONNECT| G[Opaque TCP tunnel]
    F -->|HTTP or WebSocket| H[Sanitized HTTP relay]
    C --> I[Shared bidirectional relay]
    D --> I
    G --> I
    H --> I

SOCKS version bytes are unambiguous, so they can be classified without parsing the whole request. Everything else enters the HTTP path, where bytes are accumulated until a complete header delimiter appears. The header buffer is capped at 64 KiB and the initial request has a 30-second timeout. Those limits matter: an idle or malicious client should not be able to reserve memory forever by sending a header one byte at a time.

The important detail is that classification only chooses a handshake parser. It does not discard the first read. Each SOCKS parser receives the original bytes, consumes exactly the fields it needs, and carries any leftover bytes forward. That is how the implementation handles both fragmentation and coalescing:

  • a SOCKS5 method list can arrive across several TCP reads;
  • a complete SOCKS4a request can arrive with the first payload bytes attached;
  • a CONNECT header can arrive with the beginning of a TLS ClientHello in the same read.

TCP is a byte stream, not a message queue. Treating each receive callback as a complete protocol message is an easy way to build a proxy that works in a demo and fails under real network timing.

Protocol handshakes end at one relay

The protocol handlers do only the work that is unique to their protocol:

  • HTTP removes proxy-only and connection-scoped headers before forwarding a normal request.
  • CONNECT parses the authority, opens the target, and returns 200 Connection Established.
  • SOCKS4 and SOCKS4a parse an IPv4 address or a domain following the user ID.
  • SOCKS5 negotiates an authentication method, optionally verifies username and password, then parses IPv4, IPv6, or domain targets.

After that, they all register the same two-way mapping between client and target connections.

Sequence diagram Preparing diagram
View diagram source
sequenceDiagram
    participant C as Client
    participant P as MonoProxy
    participant T as Target

    C->>P: SOCKS5 greeting and methods
    P-->>C: Selected method
    opt Authentication enabled
        C->>P: Username and password
        P-->>C: Authentication result
    end
    C->>P: CONNECT target
    P->>T: Open TCP connection
    T-->>P: Connection ready
    P-->>C: SOCKS success reply
    Note over P: Register relay only after the reply is delivered
    par Upload
        C->>P: Application bytes
        P->>T: Forward after send completion
    and Download
        T->>P: Response bytes
        P->>C: Forward after send completion
    end

Reply ordering is more than protocol etiquette. A SOCKS client must see the SOCKS success reply before any target bytes. MonoProxy therefore waits for that reply's send completion before starting both relay directions and flushing payload bytes that arrived with the request.

CONNECT has a similar race. Some clients send the TLS ClientHello immediately after the CONNECT header. MonoProxy marks the client as a tunnel before the target is ready, holds early bytes in a bounded pending buffer, sends the HTTP success response, and only then flushes the pending TLS bytes. Without that ordering, an unlucky ClientHello can be parsed as a second HTTP request or forwarded before the client has received tunnel confirmation.

Backpressure is a correctness feature

Large transfers expose a different failure mode. A fast sender and a slower receiver can make an eager read loop accumulate unbounded Data objects. On a phone, that is not merely inefficient; it can end the process.

The relay reads a bounded chunk, sends it to the other connection, and schedules the next read only after Network.framework reports that the send was processed. Tunnel reads are capped at 256 KiB. Normal HTTP response reads use 64 KiB chunks. This creates a simple backpressure chain:

Flow diagram Preparing diagram
View diagram source
flowchart LR
    A[Receive bounded chunk] --> B[Record byte counts]
    B --> C[Send to peer]
    C --> D{Send processed}
    D -->|Yes| A
    D -->|Error| E[Idempotent cleanup]

Traffic counters are batched as well. The relay records every byte, but UI-facing updates do not need to occur for every network callback. Batching keeps observability from competing with the transfer it is observing.

Capacity is bounded at admission time, before protocol parsing allocates more state. MonoProxy applies a global connection ceiling and a configurable per-client ceiling. Rejections are counted in the diagnostic model, so overload is visible instead of looking like a random network failure.

These controls do not turn an iPhone into a data-center proxy, and they are not presented as a throughput benchmark. They make memory growth and client fairness explicit, which is the useful guarantee on a mobile device.

EOF does not always mean "close both sockets"

A tunnel is full duplex. One side can finish sending while it still expects to receive data. Closing both connections at the first EOF truncates valid responses, especially for protocols that use a half-close to signal that a request body is complete.

MonoProxy tracks completion separately for each direction:

State diagram Preparing diagram
View diagram source
stateDiagram-v2
    [*] --> Relaying
    Relaying --> ClientHalfClosed: Client EOF
    Relaying --> TargetHalfClosed: Target EOF
    ClientHalfClosed --> Closed: Target EOF
    TargetHalfClosed --> Closed: Client EOF
    ClientHalfClosed --> ClientHalfClosed: Drain target response
    TargetHalfClosed --> TargetHalfClosed: Drain client data
    ClientHalfClosed --> Closed: Drain timeout
    TargetHalfClosed --> Closed: Drain timeout
    Closed --> [*]

When one input completes, the relay sends a TCP FIN to the opposite output but preserves the reverse path. A 30-second drain timer prevents abandoned half-closed connections from living forever. Cleanup is idempotent because both connection state callbacks, send failures, and timers may discover the same dead tunnel.

This is one of those details users should never notice. They only notice the broken version, usually as a download that ends early or a request that hangs after all bytes were sent.

Security starts before authentication

A local proxy should not assume that every device on the current network is trusted. MonoProxy layers several controls:

  1. Listener scope. The server can stay on loopback or bind to a selected network interface when nearby devices need access.
  2. Client policy. Private/local-only mode and IPv4 or IPv6 allow/block rules are evaluated before a connection is admitted.
  3. Authentication. HTTP and CONNECT use proxy authentication; SOCKS5 supports username/password negotiation. Comparisons run over the complete byte sequences instead of returning at the first mismatch.
  4. Failure throttling. Repeated failures are tracked per client. Five failures inside a 60-second window trigger a five-minute block, and the tracking table itself is bounded.
  5. Protocol honesty. SOCKS4 has no password-authentication mechanism. When authentication is required, MonoProxy rejects SOCKS4 instead of creating a false sense of protection.

Basic proxy credentials are encoded, not encrypted. They should be used on a network you control. HTTPS traffic itself remains an opaque end-to-end tunnel: MonoProxy does not install a certificate, terminate TLS, or inspect encrypted content.

The public MonoProxy product site describes the user-facing privacy boundary. The engineering boundary is equally important: validate lengths, bound buffers, time out incomplete states, and make every rejection observable.

Tests should target ordering, not only happy paths

The integration suite is built around the points where stream implementations usually fail. It includes fragmented maximum-length SOCKS4 user IDs, coalesced payload bytes, pending SOCKS5 authentication that becomes blocked mid-handshake, repeated mixed HTTP/CONNECT/SOCKS5 cycles, bounded connection bursts, an 8 MiB deterministic HTTP payload, and a 4 MiB payload through authenticated SOCKS5.

There are also restart loops, global-capacity rejection checks, public-site reachability cases, and HLS segment transfers. These are not marketing benchmarks. Their value is that they force the relay to preserve ordering and byte counts while timers, connection callbacks, and cleanup paths overlap.

The deliberate limits

MonoProxy currently implements the CONNECT command for SOCKS. It does not claim SOCKS BIND or UDP association. Normal HTTP forwarding uses an independent target connection per request and requires explicit request-body length; ambiguous transfer framing is rejected instead of guessed. HTTPS and other tunnel traffic stay opaque.

Those constraints keep the shared-port model understandable. The listener identifies the protocol, the handshake establishes a target, and one bounded relay moves bytes without pretending to understand them. That separation is what lets five proxy variants share one port without turning the core into five different servers.

MonoWare より

MonoProxy にはこのノートの背景があります。

製品サイトを開くか、MonoProxy のノートを続けて読めます。

ニュースレター

プライバシーと製品技術ノートを、ときどき送信します。