You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adds client-side proxy support at the io layer, hooked into hio_connect(), so any client built on it (TcpClient, HttpClient, ...) can connect through a proxy. The framework is protocol-tagged (proxy_protocol_e); SOCKS5 (RFC 1928 + RFC 1929 username/password auth) is the first and currently only implemented protocol.
Only the client side (dial out through a proxy) is added; the server side stays an example (examples/socks5_proxy_server.c), since a proxy server is an application, not an io capability.
Design
The socket is created for the proxy address, so hio_connect() dials the proxy directly (a single socket, no fd recreation). The proxy handshake then runs before the connection is handed to the upper layer — mirroring how SSL is done at the io layer:
create socket [proxy] -> TCP connect [proxy]
-> proxy handshake (SOCKS5 CONNECT to target)
-> (optional) TLS handshake against the target -> connect_cb
event/hloop.h: proxy_protocol_e { NONE, SOCKS5 } + proxy_setting_t { protocol, proxy_host, proxy_port, target_host, target_port, username, password }. The setting carries the final target the proxy should CONNECT to plus optional credentials.
event/socks5.{h,c}: internal proxy_conn_t runtime state (copied setting + fragmentation-safe read accumulator) + SOCKS5 request builders. Target is sent as ATYP=domain for hostnames, ATYP=ipv4/ipv6 for IP literals.
event/nio.c: socks5_handshake state machine (method negotiation → [user/pass auth] → CONNECT <target>:<port> → reply), driven non-blockingly via hio_add, exactly like ssl_client_handshake (raw recv into an internal buffer; never touches io->read_cb). proxy_handshake_start() dispatches by protocol. Handshake sends use a dedicated raw send() (no upper-layer write_cb, no hssl_write on a not-yet-created SSL handle). TLS SNI falls back to proxy_setting.target_host when the socket is the proxy.
event/hevent.{h,c}: io->proxy field, hio_set_proxy(io, setting) (copies the setting, validates the protocol), init/cleanup.
evpp/Channel.h, evpp/TcpClient.h: setProxy(proxy_setting_t*). TcpClient::createsocket() is called with the proxy address; the normal (async-DNS) connect path resolves and dials the proxy, and the target is delivered via setProxy.
API
// C — create the socket for the PROXY, then set the target + auth:hio_t*io=hio_create_socket(loop, proxy_host, proxy_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE);
proxy_setting_ts;
memset(&s, 0, sizeof(s));
s.protocol=PROXY_PROTOCOL_SOCKS5;
strcpy(s.target_host, "target.example.com"); s.target_port=1234;
// optional: strcpy(s.username, ...); strcpy(s.password, ...);hio_set_proxy(io, &s); // before hio_connecthio_connect(io);
Protocol: SOCKS5 only (proxy_protocol_e leaves room for more).
Auth: no-auth + username/password (RFC 1929). No GSSAPI.
Covered by epoll / kqueue / wepoll (all use nio.c). The legacy IOCP backend (overlapio.c) is not wired — it is no longer maintained and superseded by wepoll on Windows.
Testing
make libhv ✅, make socks5_client_test ✅.
End-to-end against libhv's own socks5_proxy_server and a fragmenting fake proxy (2-byte replies split byte-by-byte with delay):
IPv4 target (ATYP=1) → echo round-trips ✅
hostname target (localhost, resolved by the proxy, ATYP=domain) → ✅
username/password auth proxy → ✅
fragmented method/auth replies → ✅ (no data loss; no onWriteComplete before onConnection)
Docs / examples
examples/socks5_client_test.c (pure C, io layer), docs/cn/socks5.md, build wiring for Makefile/CMake/Bazel.
Add SOCKS5 (RFC 1928 + RFC 1929 user/pass auth) client proxy support hooked
into hio_connect(), so any client built on it (TcpClient, HttpClient, ...)
can connect through a SOCKS5 proxy. Only the client side (dial out through a
proxy); the server side remains an example (examples/socks5_proxy_server.c).
- event/socks5.{h,c}: socks5_setting_t (user config, like unpack_setting_t /
reconn_setting_t) + internal socks5_conn_t runtime state; request builders.
- event/nio.c: socks5_handshake state machine (method negotiation ->
[user/pass auth] -> CONNECT <domain>:<port> -> reply), driven non-blockingly
via hio_add like the SSL handshake. hio_connect() dials the proxy instead of
the target and records the target (sent as a domain, ATYP=domain, so the
proxy resolves it). SOCKS5 runs before the optional SSL handshake.
- event/hevent.{h,c}: io->socks5 field, hio_set_socks5(io, setting) (copies
the setting), init/cleanup.
- event/hloop.h: hio_set_socks5 declaration.
- evpp/Channel.h, evpp/TcpClient.h: setSocks5Proxy(socks5_setting_t*); the
TcpClient skips client-side DNS for a hostname target when a proxy is set
(the proxy resolves it) and stores the config as socks5_setting_t* (mirrors
reconn_setting/unpack_setting).
- examples/socks5_client_test.cpp + docs/cn/socks5.md + build wiring
(Makefile/CMake/Bazel headers).
Covered by epoll/kqueue/wepoll (nio.c); the legacy IOCP backend is not wired
(it is no longer maintained, superseded by wepoll on Windows).
Verified end-to-end against libhv's own socks5_proxy_server: IPv4 target,
hostname target (resolved by the proxy), and username/password auth all
round-trip through the proxy.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
- socket address family: with a SOCKS5 proxy, the socket was created with the
target's family but hio_connect() repointed peeraddr to the proxy, which can
resolve to a different family (EAFNOSUPPORT). Now recreate the fd with the
proxy's family (detach/close/attach) when it differs from the target family.
- auth request buffer: enlarge the handshake buffer to 640 bytes; an RFC 1929
username/password request can be up to 513 bytes and overran the 512 buffer.
- move socks5_setting_t (public config) into hloop.h; socks5.h is now an
internal header (not installed). This keeps the public API self-contained in
hloop.h and stops exporting an internal header.
- C init: document that C callers must zero socks5_setting_t (memset / = {0});
C++ keeps a default constructor. (no extra _init function; memset is enough)
- examples: add socks5_client_test to CMake (was Make-only).
- docs: fix the C++ API declaration (setSocks5Proxy is on SocketChannel).
Still epoll/kqueue/wepoll only (nio.c); legacy IOCP backend not wired.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
send() on a nonblocking socket may write fewer than n bytes or return EAGAIN; treating any short write as a handshake failure can reject valid connections under backpressure. Queue/retain the unsent bytes and resume on HV_WRITE rather than requiring one complete write.
This issue also appears in the following locations of the same file:
line 287
line 305
Buffer fragmented SOCKS5 method replies
event/nio.c:268
A TCP read is not guaranteed to return both bytes of this SOCKS5 method reply. If the proxy fragments the reply, this consumes the first byte and the n < 2 check closes a valid connection instead of waiting for the remaining byte. Accumulate the fixed-length reply (and preserve partial bytes) before parsing it.
This issue also appears on line 294 of the same file.
Validate the RFC 1929 reply version
event/nio.c:297
RFC 1929 requires the subnegotiation reply version to be 0x01, but this check validates only the status byte. A malformed or incompatible proxy returning another version with status 0 would be accepted and the client would continue parsing the stream as SOCKS5.
Preserve the original hostname for proxy resolution
event/nio.c:619
For a raw hio_create_socket(loop, "hostname", ...) client, the original hostname is not stored in hio_t; only the synchronously resolved peer address is retained. Unless the caller separately calls hio_set_hostname, this fallback sends the resolved IP to the proxy, so the proxy does not perform the documented proxy-side hostname resolution. Preserve the original host or make that requirement explicit.
Address the deeper review findings:
- Fragmentation: the 2-byte method/auth replies (and the variable CONNECT
reply) are now accumulated in socks5_conn_t (rbuf/rlen/want) until a full
step is available, so a reply split across TCP segments no longer fails the
handshake. Previously a short read was treated as fatal.
- read_cb hijack (regression fix): drive the handshake via
hio_add(io, socks5_handshake, HV_READ) doing raw recv into the accumulator,
exactly like ssl_client_handshake, and NEVER touch io->read_cb. The earlier
hio_readbytes/hio_setcb_read approach overwrote (and then cleared) the
upper-layer Channel's read_cb, so after the tunnel came up user data was
dropped and onMessage never fired. Handoff now just hio_del(HV_READ) and runs
the SSL handshake / connect_cb with read_cb intact.
- send: handshake requests go through hio_write (handles partial writes),
instead of a bare send() whose short write was treated as fatal.
- ATYP: an IPv4/IPv6 literal target is now sent as ATYP=1/4 (raw address) per
RFC 1928; only real hostnames use ATYP=domain. Previously '127.0.0.1'/'::1'
were sent as domains, which strict proxies reject and '::1' cannot resolve.
- remove dead code socks5_connect_reply_len().
Verified end-to-end (echo round-trip) with: real libhv proxy; a fake proxy
that splits the method reply and the auth reply 1+delay+1 byte; and
username/password auth with a domain target. All deliver data correctly now.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Preserve proxy target hostname separately from SNI
event/nio.c:655
io->hostname is the SNI field, not a guaranteed copy of the original connect target. In particular, hio_create_socket() resolves a hostname and stores only the resulting peer address, so a generic C client using hio_create_socket(loop, "name", ...) still performs local DNS and this fallback sends the resolved IP instead of an ATYP=domain request; a caller that sets a custom SNI can also make the proxy connect to the wrong host. Preserve the target hostname separately (or add an explicit target-host API) rather than reusing the SNI field.
- send handshake packets via a dedicated raw send() instead of hio_write():
hio_write leaked handshake bytes (incl. credentials) to the upper-layer
write_cb before onConnection, dispatched to hssl_write() with a not-yet-
created SSL handle for TLS targets, and could clobber the handshake read
handler by enqueueing on EAGAIN. Short write/error is treated as fatal
(handshake runs on a fresh connection with an empty send buffer, <=513B).
- examples/BUILD.bazel: add socks5_client_test target (guarded by with_evpp).
- docs/cn/socks5.md: remove duplicated typedef block; note that a hostname
proxy resolves synchronously on the loop thread.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
The SOCKS5 proxy support lives at the io (C) layer, so the example is more
appropriate as plain C using hio_set_socks5() directly rather than the C++
TcpClient wrapper. This also drops the with_evpp guard in all three build
systems (Makefile/CMake/Bazel), since the C example has no C++ dependency.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
hio_create_socket(host) resolves the name immediately and only keeps the
numeric sockaddr, so hio_connect() would fall back to that literal (ATYP=1)
and a proxy-only-resolvable name would fail locally. Create the socket with a
loopback placeholder and set the real target via hio_set_hostname(): the
handshake then sends CONNECT <hostname> (ATYP=domain), and hio_connect()
recreates the fd with the proxy family as needed. Verified against a fake
proxy: domain target -> atyp=3, ipv4 literal -> atyp=1.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Handle partial and blocked nonblocking handshake writes
event/nio.c:285
A nonblocking send() is allowed to return a short count (or EAGAIN), even for these small messages. Treating any result other than len as fatal can send a partial method/auth/CONNECT request and close otherwise valid connections under a constrained send buffer or transient backpressure. Track the unsent handshake bytes and resume on HV_WRITE, or use an equivalent nonblocking write path that preserves handshake ordering.
This issue also appears on line 327 of the same file.
SOCKS5 uses resolved address instead of original hostname
event/nio.c:674
When the caller uses the low-level API as documented (hio_create_socket(loop, "example.com", ...) followed by hio_set_socks5), hio_create_socket has already resolved the name and does not populate io->hostname. This fallback therefore sends the resolved numeric address instead of the original domain, so the proxy does not perform the promised proxy-side DNS resolution. Preserve the original host for SOCKS5 or explicitly require hio_set_hostname before hio_connect and update the public API documentation to match.
- example: clarify that hio_create_socket's port must be the real target port
(only host is a placeholder for the address family; hio_connect takes the
port from peeraddr).
- docs/cn/socks5.md: the target is only sent as ATYP=domain for hostnames;
IP literals go as ATYP=ipv4/ipv6 (fix stale wording). Add a C usage section
showing the placeholder-host + hio_set_hostname pattern so C users don't hit
'create socket failed' by passing a proxy-only-resolvable hostname to
hio_create_socket.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
C layer does have async DNS (event/hdns), so 'no async DNS' was wrong. The
actual reason not to pass a proxy-only-resolvable hostname to
hio_create_socket() is that this low-level API resolves synchronously via
getaddrinfo (it does not go through hdns), so such a name fails locally.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Because this is a nonblocking socket, send() is allowed to return EAGAIN or fewer than len bytes even for these small messages. Treating any short write as fatal can abort valid connections (especially with a full send buffer or maximum-length credentials); retain the unwritten bytes and resume the handshake on HV_WRITE without replacing its read callback.
SOCKS5 settings ignored by legacy EVENT_IOCP backend
event/hevent.c:508
This setter succeeds on every backend, but overlapio.c's hio_connect() never consumes io->socks5; builds selecting the legacy EVENT_IOCP backend therefore silently connect without the requested proxy (and hostname targets may use the placeholder address). Since WITH_WEPOLL=OFF still permits this backend, reject the feature there or propagate an unsupported-backend error instead of returning success.
Handle partial and interrupted nonblocking handshake sends
event/nio.c:285
send() on this nonblocking socket may return a short count, EAGAIN, or EINTR; treating every result other than exactly len as fatal can abort otherwise valid proxy connections under backpressure or a signal. Keep the unsent handshake bytes and resume on HV_WRITE (without invoking the upper-layer write callback) before advancing the handshake state.
Validate SOCKS5 authentication reply version
event/nio.c:329
RFC 1929 replies include a version byte, but this branch only checks the status and accepts a malformed response with any buf[0]. Validate buf[0] == SOCKS5_AUTH_VERSION as well before proceeding to CONNECT.
Rework client-side proxy so the socket is created for the PROXY address and
never rebuilt. Replaces socks5_setting_t/hio_set_socks5 with a protocol-tagged
proxy_setting_t + hio_set_proxy:
- hloop.h: proxy_protocol_e (NONE/SOCKS5) + proxy_setting_t
{protocol, proxy_host, proxy_port, target_host, target_port, user, pass}.
hio_set_proxy() validates the protocol (only SOCKS5 implemented).
- hevent: io->socks5 -> io->proxy (proxy_conn_t holds the copied setting +
handshake accumulator); build_* read setting.target_host/target_port.
- nio.c: hio_connect() just connect()s the (proxy-bound) fd; proxy_handshake_
start() dispatches by protocol. Removes the previous connect-time fd rebuild
path entirely.
- evpp: Channel::setProxy / TcpClient::setProxy; TcpClient resolves the proxy
address (numeric direct, hostname via async hdns) and connects the socket to
the proxy, passing createsocket()'s host/port as the target.
- example rewritten to hio_create_socket(proxy) + hio_set_proxy(target); docs
updated for the new API.
Verified end-to-end (C example + TcpClient) against libhv's socks5_proxy_server
and a fragmenting fake proxy: no-auth and user/pass auth, IPv4 and domain
targets, replies split byte-by-byte -- all deliver data, no writes before
onConnection.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
…a proxy
- TcpClient: drop the separate startConnectViaProxy path. createsocket() now
connects to the PROXY (createsocket(proxy_port, proxy_host)) and setProxy()
carries the target; the normal DNS/connect path resolves the proxy, so a
single socket is used (no discard/recreate of a target socket). This also
fixes the Copilot findings about reusing a pre-created target channel and the
DNS-fallback connecting to the target.
- nio.c: when using a proxy over TLS, io->hostname is unset (socket is the
proxy), so fall back to proxy_setting.target_host for hssl_set_sni_hostname
(an explicit hio_set_hostname still wins). Fixes omitted SNI for HTTPS via
proxy.
- hloop.h: keep proxy_host/proxy_port in proxy_setting_t for reference (SOCKS5
path does not require them since the socket already is the proxy connection).
- example/docs updated to createsocket(proxy) + setProxy(target).
Verified end-to-end (C example + TcpClient) against libhv's socks5_proxy_server
and a fragmenting fake proxy: no-auth + user/pass auth, IPv4 + domain targets,
byte-split replies -- all deliver data, no writes before onConnection.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
send() on this nonblocking socket is allowed to return a short count or EAGAIN, especially for the up-to-513-byte username/password request. Treating every result other than exactly len as fatal can reject valid connections under a small send buffer or transient backpressure; retain the unsent bytes and resume on writability instead.
Missing timeout for asynchronous SOCKS handshake
event/nio.c:439
When the underlying nonblocking connect() returns 0, hio_connect() schedules nio_connect_async() without creating the connect timer. This branch then starts an asynchronous SOCKS handshake, so a proxy that accepts TCP but never replies leaves the connection open indefinitely despite connect_timeout; start/retain a timeout for the proxy handshake on this path.
TcpClient SOCKS5 API name and type mismatch
evpp/TcpClient.h:375
The PR API specifies TcpClient::setSocks5Proxy(socks5_setting_t*), but this change exposes setProxy(proxy_setting_t*) instead. Code using the promised method/type will not compile, and the docs/examples now describe a different API; please keep the public name and setting type consistent across the implementation, headers, and description.
Through a proxy the TLS peer is the target, so proxy_setting.target_host is
authoritative for SNI; fall back to io->hostname only when no proxy target is
set. (nio_connect_established; matches TcpClient's SNI selection.)
Co-authored-by: TRAE CLI <traecli@bytedance.com>
This uses a raw send() on a nonblocking socket and treats any short write or EAGAIN as a fatal SOCKS5 failure. Nonblocking TCP sends are allowed to write only part of the request (the username/password request can be over 500 bytes), so valid proxy connections can fail under buffer pressure; retain the unwritten handshake bytes and resume on HV_WRITE instead of closing.
…name)
SNI must be a hostname, not an IP literal (RFC 6066). Check is_ipaddr on each
candidate independently: if the proxy target_host is numeric, skip it and fall
back to io->hostname (which may be an explicitly-set domain) rather than
dropping SNI entirely.
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Proxy handshake mishandles partial and interrupted sends
event/nio.c:295
send() on a nonblocking stream may return a short count, EAGAIN, or EINTR; treating every result other than exactly len as fatal can close an otherwise valid proxy connection during method/auth/CONNECT negotiation under backpressure. Preserve the unsent suffix and wait for HV_WRITE (or otherwise complete the raw send) before advancing the handshake.
TcpClient::setProxy accepts any protocol, but SocketChannel::setProxy/hio_set_proxy can reject it (for example PROXY_PROTOCOL_NONE), and this return value is ignored. The client then proceeds to connect to remote_host—which is the proxy address—and invokes onConnection as if the target tunnel were established. Propagate or handle the failure before starting the connection.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds client-side proxy support at the io layer, hooked into
hio_connect(), so any client built on it (TcpClient,HttpClient, ...) can connect through a proxy. The framework is protocol-tagged (proxy_protocol_e); SOCKS5 (RFC 1928 + RFC 1929 username/password auth) is the first and currently only implemented protocol.Only the client side (dial out through a proxy) is added; the server side stays an example (
examples/socks5_proxy_server.c), since a proxy server is an application, not an io capability.Design
The socket is created for the proxy address, so
hio_connect()dials the proxy directly (a single socket, no fd recreation). The proxy handshake then runs before the connection is handed to the upper layer — mirroring how SSL is done at the io layer:event/hloop.h:proxy_protocol_e { NONE, SOCKS5 }+proxy_setting_t { protocol, proxy_host, proxy_port, target_host, target_port, username, password }. The setting carries the final target the proxy should CONNECT to plus optional credentials.event/socks5.{h,c}: internalproxy_conn_truntime state (copied setting + fragmentation-safe read accumulator) + SOCKS5 request builders. Target is sent as ATYP=domain for hostnames, ATYP=ipv4/ipv6 for IP literals.event/nio.c:socks5_handshakestate machine (method negotiation → [user/pass auth] →CONNECT <target>:<port>→ reply), driven non-blockingly viahio_add, exactly likessl_client_handshake(rawrecvinto an internal buffer; never touchesio->read_cb).proxy_handshake_start()dispatches by protocol. Handshake sends use a dedicated rawsend()(no upper-layerwrite_cb, nohssl_writeon a not-yet-created SSL handle). TLS SNI falls back toproxy_setting.target_hostwhen the socket is the proxy.event/hevent.{h,c}:io->proxyfield,hio_set_proxy(io, setting)(copies the setting, validates the protocol), init/cleanup.evpp/Channel.h,evpp/TcpClient.h:setProxy(proxy_setting_t*).TcpClient::createsocket()is called with the proxy address; the normal (async-DNS) connect path resolves and dials the proxy, and the target is delivered viasetProxy.API
Scope / platform
proxy_protocol_eleaves room for more).nio.c). The legacy IOCP backend (overlapio.c) is not wired — it is no longer maintained and superseded by wepoll on Windows.Testing
make libhv✅,make socks5_client_test✅.socks5_proxy_serverand a fragmenting fake proxy (2-byte replies split byte-by-byte with delay):localhost, resolved by the proxy, ATYP=domain) → ✅onWriteCompletebeforeonConnection)Docs / examples
examples/socks5_client_test.c(pure C, io layer),docs/cn/socks5.md, build wiring for Makefile/CMake/Bazel.