From adee330e699aa95704ab7b5c247d2fdb24f8ee3e Mon Sep 17 00:00:00 2001 From: ithewei Date: Sun, 20 Sep 2026 16:39:18 +0800 Subject: [PATCH 01/12] feat(event): add client-side SOCKS5 proxy support at the io layer 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 : -> 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 --- BUILD.bazel | 1 + Makefile | 5 +- Makefile.vars | 1 + cmake/vars.cmake | 1 + docs/cn/socks5.md | 82 ++++++++++++ event/hevent.c | 13 ++ event/hevent.h | 4 + event/hloop.h | 12 ++ event/nio.c | 212 +++++++++++++++++++++++++++----- event/socks5.c | 72 +++++++++++ event/socks5.h | 71 +++++++++++ evpp/Channel.h | 7 ++ evpp/TcpClient.h | 43 +++++++ examples/socks5_client_test.cpp | 63 ++++++++++ 14 files changed, 555 insertions(+), 32 deletions(-) create mode 100644 docs/cn/socks5.md create mode 100644 event/socks5.c create mode 100644 event/socks5.h create mode 100644 examples/socks5_client_test.cpp diff --git a/BUILD.bazel b/BUILD.bazel index 1a229bde3..578d9ef8b 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -295,6 +295,7 @@ EVENT_HEADERS = [ "event/hloop.h", "event/nlog.h", "event/hdns.h", + "event/socks5.h", ] UTIL_HEADERS = [ diff --git a/Makefile b/Makefile index 9e8cd0b9f..06f06c8f3 100644 --- a/Makefile +++ b/Makefile @@ -101,7 +101,7 @@ EXAMPLES = hmain_test htimer_test hloop_test pipe_test \ jsonrpc ifeq ($(WITH_EVPP), yes) -EXAMPLES += nmap +EXAMPLES += nmap socks5_client_test ifeq ($(WITH_REDIS), yes) EXAMPLES += redis_client_example redis_subscriber_example endif @@ -270,6 +270,9 @@ tinyproxyd: prepare nmap: prepare libhv $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS) cpputil examples/nmap" DEFINES="PRINT_DEBUG" +socks5_client_test: prepare libhv + $(CXX) -g -Wall -O0 -std=c++11 -I. -Ibase -Issl -Ievent -Icpputil -Ievpp -o bin/socks5_client_test examples/socks5_client_test.cpp -Llib -lhv -pthread + ifeq ($(WITH_REDIS), yes) redis_client_example: prepare $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS) cpputil evpp redis" SRCS="examples/redis_client_test.cpp" diff --git a/Makefile.vars b/Makefile.vars index f51418a97..a54d47d44 100644 --- a/Makefile.vars +++ b/Makefile.vars @@ -47,6 +47,7 @@ SSL_HEADERS = ssl/hssl.h EVENT_HEADERS = event/hloop.h\ event/nlog.h\ event/hdns.h\ + event/socks5.h\ UTIL_HEADERS = util/base64.h\ util/md5.h\ diff --git a/cmake/vars.cmake b/cmake/vars.cmake index 83e7afd7f..dfa38c121 100644 --- a/cmake/vars.cmake +++ b/cmake/vars.cmake @@ -28,6 +28,7 @@ set(EVENT_HEADERS event/hloop.h event/nlog.h event/hdns.h + event/socks5.h ) set(UTIL_HEADERS diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md new file mode 100644 index 000000000..a8a498bec --- /dev/null +++ b/docs/cn/socks5.md @@ -0,0 +1,82 @@ +SOCKS5 代理客户端 + +在事件循环(io)层内置的 SOCKS5 客户端代理支持(RFC 1928 + RFC 1929 用户名/密码认证)。 + +设置后,`hio_connect()` 会先连接到 SOCKS5 代理并完成握手(向代理发起 CONNECT 到目标地址,目标以域名形式发送,由代理解析),握手成功后连接对上层透明;若开启了 SSL,则在隧道之上再与目标做 TLS 握手。 + +由于挂在 `hio_connect` 上,所有基于它的客户端(`TcpClient`、`HttpClient` 等)都能直接使用。 + +> 说明: +> - 只做客户端代理(通过代理连出去),服务端见 [examples/socks5_proxy_server.c](../../examples/socks5_proxy_server.c)。 +> - 支持无认证与用户名/密码认证(不支持 GSSAPI)。 +> - 目标地址以域名(ATYP=domain)发送给代理解析,因此配了代理时客户端本地不再做 DNS。 + +## 配置结构 socks5_setting_t + +```c +typedef struct socks5_setting_s { + char host[256]; // 代理主机 + int port; // 代理端口 + char username[256]; // 空 => 无认证 + char password[256]; +} socks5_setting_t; +``` + +## C 接口 + +```c +// 设置 SOCKS5 代理(setting 会被拷贝);在 hio_connect() 之前调用。 +int hio_set_socks5(hio_t* io, socks5_setting_t* setting); +``` + +## C++ 接口 + +```c++ +namespace hv { + +// Channel / SocketChannel +int Channel::setSocks5Proxy(socks5_setting_t* setting); + +// TcpClient +void TcpClient::setSocks5Proxy(socks5_setting_t* setting); + +} +``` + +## 示例 + +```c++ +#include "TcpClient.h" +using namespace hv; + +int main() { + TcpClient cli; + cli.createsocket(1234, "target.example.com"); // 目标(可为域名,由代理解析) + + socks5_setting_t socks5; + hv_strncpy(socks5.host, "127.0.0.1", sizeof(socks5.host)); + socks5.port = 1080; + // 如需认证: hv_strncpy(socks5.username, "user", ...); hv_strncpy(socks5.password, "pass", ...); + cli.setSocks5Proxy(&socks5); + + cli.onConnection = [](const SocketChannelPtr& channel) { + if (channel->isConnected()) channel->write("hello via socks5"); + }; + cli.onMessage = [](const SocketChannelPtr& channel, Buffer* buf) { + printf("recv: %.*s\n", (int)buf->size(), (char*)buf->data()); + }; + cli.start(); + while (1) hv_sleep(1); + return 0; +} +``` + +测试代码见 [examples/socks5_client_test.cpp](../../examples/socks5_client_test.cpp) + +可用 libhv 自带的 SOCKS5 代理服务端做端到端测试: + +```sh +bin/tcp_echo_server 1234 +bin/socks5_proxy_server 1080 +bin/socks5_client_test 127.0.0.1 1080 127.0.0.1 1234 +``` diff --git a/event/hevent.c b/event/hevent.c index 25bfb8b4f..454e1a112 100644 --- a/event/hevent.c +++ b/event/hevent.c @@ -5,6 +5,7 @@ #include "herr.h" #include "unpack.h" +#include "socks5.h" uint64_t hloop_next_event_id() { static hatomic_t s_id = HATOMIC_VAR_INIT(0); @@ -135,6 +136,7 @@ void hio_ready(hio_t* io) { io->ssl_ctx = NULL; io->alloced_ssl_ctx = 0; io->hostname = NULL; + io->socks5 = NULL; // context io->ctx = NULL; // private: @@ -495,6 +497,17 @@ const char* hio_get_hostname(hio_t* io) { return io->hostname; } +int hio_set_socks5(hio_t* io, socks5_setting_t* setting) { + if (io == NULL || setting == NULL) return -1; + if (io->socks5 == NULL) { + HV_ALLOC_SIZEOF(io->socks5); + if (io->socks5 == NULL) return -1; + } + // copy the user config; runtime fields (target/state) are filled at connect + io->socks5->setting = *setting; + return 0; +} + void hio_del_connect_timer(hio_t* io) { if (io->connect_timer) { htimer_del(io->connect_timer); diff --git a/event/hevent.h b/event/hevent.h index 7f3289132..30d287015 100644 --- a/event/hevent.h +++ b/event/hevent.h @@ -186,6 +186,10 @@ struct hio_s { void* ssl; // for hio_set_ssl void* ssl_ctx; // for hio_set_ssl_ctx char* hostname; // for hssl_set_sni_hostname + // socks5 proxy (client side): if set, hio_connect dials the proxy and + // performs a SOCKS5 handshake (CONNECT to the original target) before the + // connection is handed to the upper layer / SSL handshake. + struct socks5_conn_s* socks5; // context void* ctx; // for hio_context / hio_set_context // private: diff --git a/event/hloop.h b/event/hloop.h index a1c89e6a3..f8d90dbbb 100644 --- a/event/hloop.h +++ b/event/hloop.h @@ -344,6 +344,18 @@ HV_EXPORT hssl_ctx_t hio_get_ssl_ctx(hio_t* io); HV_EXPORT int hio_set_hostname(hio_t* io, const char* hostname); HV_EXPORT const char* hio_get_hostname(hio_t* io); +// SOCKS5 proxy (client side). When set, hio_connect() dials the proxy at +// setting->host:port and performs a SOCKS5 handshake (RFC 1928), issuing a +// CONNECT to the io's original target (sent as a domain name, ATYP=domain, so +// the proxy resolves it). After the handshake succeeds the connection is +// transparent and (if SSL was enabled) the TLS handshake runs against the +// target. Because it hooks hio_connect, all clients built on it (TcpClient, +// HttpClient, ...) can use it. The setting is copied. Pass an empty username +// for no auth, or a username/password for RFC 1929 auth. +// NOTE: set before hio_connect(). +struct socks5_setting_s; +HV_EXPORT int hio_set_socks5(hio_t* io, struct socks5_setting_s* setting); + // connect timeout => hclose_cb HV_EXPORT void hio_set_connect_timeout(hio_t* io, int timeout_ms DEFAULT(HIO_DEFAULT_CONNECT_TIMEOUT)); // close timeout => hclose_cb diff --git a/event/nio.c b/event/nio.c index 4980237f4..b71e48a10 100644 --- a/event/nio.c +++ b/event/nio.c @@ -6,6 +6,7 @@ #include "hlog.h" #include "herr.h" #include "hthread.h" +#include "socks5.h" static void __connect_timeout_cb(htimer_t* timer) { hio_t* io = (hio_t*)timer->privdata; @@ -196,6 +197,155 @@ static void nio_accept(hio_t* io) { // hio_close(io); } +// After the transport is connected (and, if using SOCKS5, after the proxy +// handshake completed), start the SSL handshake or deliver connect_cb. +static void nio_connect_established(hio_t* io) { + if (io->io_type == HIO_TYPE_SSL) { + if (io->ssl == NULL) { + // io->ssl_ctx > g_ssl_ctx > hssl_ctx_new + hssl_ctx_t ssl_ctx = NULL; + if (io->ssl_ctx) { + ssl_ctx = io->ssl_ctx; + } else if (g_ssl_ctx) { + ssl_ctx = g_ssl_ctx; + } else { + io->ssl_ctx = ssl_ctx = hssl_ctx_new(NULL); + io->alloced_ssl_ctx = 1; + } + if (ssl_ctx == NULL) { + io->error = ERR_NEW_SSL_CTX; + hio_close(io); + return; + } + hssl_t ssl = hssl_new(ssl_ctx, io->fd); + if (ssl == NULL) { + io->error = ERR_NEW_SSL; + hio_close(io); + return; + } + io->ssl = ssl; + } + if (io->hostname) { + hssl_set_sni_hostname(io->ssl, io->hostname); + } + ssl_client_handshake(io); + } + else { + // NOTE: SSL call connect_cb after handshake finished + __connect_cb(io); + } +} + +// SOCKS5 client handshake state machine (RFC 1928 + RFC 1929). +// Driven non-blockingly via hio_add(io, socks5_handshake, HV_READ/WRITE), +// mirroring ssl_client_handshake. Runs on the raw TCP socket to the proxy. +enum socks5_state_e { + S5_SEND_METHODS = 0, + S5_RECV_METHOD, + S5_SEND_AUTH, + S5_RECV_AUTH, + S5_SEND_CONNECT, + S5_RECV_REPLY, + S5_DONE, +}; + +static void socks5_handshake(hio_t* io) { + socks5_conn_t* s5 = io->socks5; + unsigned char buf[512]; + int n; + + switch (s5->state) { + case S5_SEND_METHODS: + n = socks5_build_method_request(s5, buf); + if (send(io->fd, (const char*)buf, n, 0) != n) goto s5_error; + s5->state = S5_RECV_METHOD; + hio_add(io, socks5_handshake, HV_READ); + return; + + case S5_RECV_METHOD: { + // reply: VER METHOD (2 bytes) + n = recv(io->fd, (char*)buf, 2, 0); + if (n == 0) goto s5_error; // peer closed + if (n < 0) { if (socket_errno()==EAGAIN||socket_errno()==EINTR) return; goto s5_error; } + if (n < 2 || buf[0] != SOCKS5_VERSION) goto s5_error; + unsigned char method = buf[1]; + if (method == SOCKS5_AUTH_NONE) { + s5->state = S5_SEND_CONNECT; + } else if (method == SOCKS5_AUTH_USERPASS && s5->setting.username[0]) { + s5->state = S5_SEND_AUTH; + } else { + goto s5_error; // no acceptable method + } + hio_del(io, HV_READ); + socks5_handshake(io); // advance immediately (send) + return; + } + + case S5_SEND_AUTH: + n = socks5_build_auth_request(s5, buf); + if (send(io->fd, (const char*)buf, n, 0) != n) goto s5_error; + s5->state = S5_RECV_AUTH; + hio_add(io, socks5_handshake, HV_READ); + return; + + case S5_RECV_AUTH: + // reply: VER STATUS (2 bytes), STATUS 0 = success + n = recv(io->fd, (char*)buf, 2, 0); + if (n == 0) goto s5_error; + if (n < 0) { if (socket_errno()==EAGAIN||socket_errno()==EINTR) return; goto s5_error; } + if (n < 2 || buf[1] != 0x00) goto s5_error; + s5->state = S5_SEND_CONNECT; + hio_del(io, HV_READ); + socks5_handshake(io); + return; + + case S5_SEND_CONNECT: + n = socks5_build_connect_request(s5, buf); + if (n < 0 || send(io->fd, (const char*)buf, n, 0) != n) goto s5_error; + s5->state = S5_RECV_REPLY; + hio_add(io, socks5_handshake, HV_READ); + return; + + case S5_RECV_REPLY: { + // reply: VER REP RSV ATYP BND.ADDR BND.PORT + // Peek the fixed 4-byte header first to learn ATYP, then drain the + // variable-length bound address so the stream starts clean. + n = recv(io->fd, (char*)buf, 4, MSG_PEEK); + if (n == 0) goto s5_error; + if (n < 0) { if (socket_errno()==EAGAIN||socket_errno()==EINTR) return; goto s5_error; } + if (n < 4) return; // wait for the full header + if (buf[0] != SOCKS5_VERSION) goto s5_error; + if (buf[1] != SOCKS5_REP_SUCCESS) { io->error = ERR_CONNECT; goto s5_error; } + unsigned char atyp = buf[3]; + int total; + if (atyp == SOCKS5_ATYP_IPV4) total = 4 + 4 + 2; + else if (atyp == SOCKS5_ATYP_IPV6) total = 4 + 16 + 2; + else if (atyp == SOCKS5_ATYP_DOMAIN) { + unsigned char hdr[5]; + if (recv(io->fd, (char*)hdr, 5, MSG_PEEK) < 5) return; // need len byte + total = 4 + 1 + hdr[4] + 2; + } else goto s5_error; + // ensure the whole reply is available, then consume it + n = recv(io->fd, (char*)buf, total, MSG_PEEK); + if (n < total) return; // wait for more + recv(io->fd, (char*)buf, total, 0); // drain + s5->state = S5_DONE; + hio_del(io, HV_READ); + // proxy tunnel established -> proceed to SSL handshake / connect_cb + nio_connect_established(io); + return; + } + + default: + return; + } + +s5_error: + if (io->error == 0) io->error = ERR_CONNECT; + hlogw("connfd=%d socks5 handshake error", io->fd); + hio_close(io); +} + static void nio_connect(hio_t* io) { // printd("nio_connect connfd=%d\n", io->fd); socklen_t addrlen = sizeof(sockaddr_u); @@ -208,39 +358,15 @@ static void nio_connect(hio_t* io) { addrlen = sizeof(sockaddr_u); getsockname(io->fd, io->localaddr, &addrlen); - if (io->io_type == HIO_TYPE_SSL) { - if (io->ssl == NULL) { - // io->ssl_ctx > g_ssl_ctx > hssl_ctx_new - hssl_ctx_t ssl_ctx = NULL; - if (io->ssl_ctx) { - ssl_ctx = io->ssl_ctx; - } else if (g_ssl_ctx) { - ssl_ctx = g_ssl_ctx; - } else { - io->ssl_ctx = ssl_ctx = hssl_ctx_new(NULL); - io->alloced_ssl_ctx = 1; - } - if (ssl_ctx == NULL) { - io->error = ERR_NEW_SSL_CTX; - goto connect_error; - } - hssl_t ssl = hssl_new(ssl_ctx, io->fd); - if (ssl == NULL) { - io->error = ERR_NEW_SSL; - goto connect_error; - } - io->ssl = ssl; - } - if (io->hostname) { - hssl_set_sni_hostname(io->ssl, io->hostname); - } - ssl_client_handshake(io); - } - else { - // NOTE: SSL call connect_cb after handshake finished - __connect_cb(io); + // SOCKS5: the TCP connection is to the proxy; run the proxy handshake + // (CONNECT to the real target) before SSL / connect_cb. + if (io->socks5) { + io->socks5->state = S5_SEND_METHODS; + socks5_handshake(io); + return; } + nio_connect_established(io); return; } @@ -475,6 +601,29 @@ int hio_accept(hio_t* io) { } int hio_connect(hio_t* io) { + // SOCKS5: the target was recorded in io (peeraddr/hostname) by + // hio_create_socket; redirect the actual TCP connect to the proxy while + // keeping the target for the CONNECT request (sent as a domain name). + if (io->socks5) { + socks5_conn_t* s5 = io->socks5; + // capture target: prefer the SNI hostname (original domain), else the + // numeric peer address. + if (io->hostname && io->hostname[0]) { + hv_strncpy(s5->target_host, io->hostname, sizeof(s5->target_host)); + } else { + sockaddr_ip((sockaddr_u*)io->peeraddr, s5->target_host, sizeof(s5->target_host)); + } + s5->target_port = sockaddr_port((sockaddr_u*)io->peeraddr); + // repoint peeraddr to the proxy + sockaddr_u proxyaddr; + memset(&proxyaddr, 0, sizeof(proxyaddr)); + if (sockaddr_set_ipport(&proxyaddr, s5->setting.host, s5->setting.port) != 0) { + io->error = ERR_INVALID_PARAM; + hio_close_async(io); + return -1; + } + hio_set_peeraddr(io, &proxyaddr.sa, sockaddr_len(&proxyaddr)); + } int ret = connect(io->fd, io->peeraddr, SOCKADDR_LEN(io->peeraddr)); #ifdef OS_WIN if (ret < 0 && socket_errno() != WSAEWOULDBLOCK) { @@ -646,6 +795,7 @@ int hio_close (hio_t* io) { io->ssl_ctx = NULL; } SAFE_FREE(io->hostname); + SAFE_FREE(io->socks5); if (io->io_type & HIO_TYPE_SOCKET) { closesocket(io->fd); } else if (io->io_type == HIO_TYPE_PIPE) { diff --git a/event/socks5.c b/event/socks5.c new file mode 100644 index 000000000..2bf364ae7 --- /dev/null +++ b/event/socks5.c @@ -0,0 +1,72 @@ +#include "socks5.h" + +#include + +// Build the SOCKS5 method-selection request. +// +----+----------+----------+ +// |VER | NMETHODS | METHODS | +// +----+----------+----------+ +// Offers NONE, plus USERPASS when auth credentials are present. +// Returns the number of bytes written. +int socks5_build_method_request(const socks5_conn_t* s5, unsigned char* buf) { + int n = 0; + buf[n++] = SOCKS5_VERSION; + if (s5->setting.username[0]) { + buf[n++] = 2; // 2 methods + buf[n++] = SOCKS5_AUTH_NONE; + buf[n++] = SOCKS5_AUTH_USERPASS; + } else { + buf[n++] = 1; // 1 method + buf[n++] = SOCKS5_AUTH_NONE; + } + return n; +} + +// Build the RFC 1929 username/password auth request. +// +----+------+----------+------+----------+ +// |VER | ULEN | UNAME | PLEN | PASSWD | +// +----+------+----------+------+----------+ +int socks5_build_auth_request(const socks5_conn_t* s5, unsigned char* buf) { + int n = 0; + int ulen = (int)strlen(s5->setting.username); + int plen = (int)strlen(s5->setting.password); + buf[n++] = SOCKS5_AUTH_VERSION; + buf[n++] = (unsigned char)ulen; + memcpy(buf + n, s5->setting.username, ulen); n += ulen; + buf[n++] = (unsigned char)plen; + memcpy(buf + n, s5->setting.password, plen); n += plen; + return n; +} + +// Build a CONNECT request using ATYP=domain (the proxy resolves the target). +// +----+-----+-------+------+----------+----------+ +// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | +// +----+-----+-------+------+----------+----------+ +// Returns bytes written, or -1 if the target host is too long. +int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf) { + int hlen = (int)strlen(s5->target_host); + if (hlen <= 0 || hlen > 255) return -1; + int n = 0; + buf[n++] = SOCKS5_VERSION; + buf[n++] = SOCKS5_CMD_CONNECT; + buf[n++] = 0x00; // RSV + buf[n++] = SOCKS5_ATYP_DOMAIN; + buf[n++] = (unsigned char)hlen; + memcpy(buf + n, s5->target_host, hlen); n += hlen; + unsigned short port = (unsigned short)s5->target_port; + buf[n++] = (unsigned char)((port >> 8) & 0xFF); + buf[n++] = (unsigned char)(port & 0xFF); + return n; +} + +// The CONNECT reply's bound-address section is variable-length by ATYP; return +// the total expected reply length for the given atyp, or -1 if unknown. +// Fixed part is 4 bytes (VER REP RSV ATYP) + addr + 2 (port). +int socks5_connect_reply_len(unsigned char atyp) { + switch (atyp) { + case SOCKS5_ATYP_IPV4: return 4 + 4 + 2; + case SOCKS5_ATYP_IPV6: return 4 + 16 + 2; + case SOCKS5_ATYP_DOMAIN: return -1; // needs the length byte, handled by caller + default: return -1; + } +} diff --git a/event/socks5.h b/event/socks5.h new file mode 100644 index 000000000..955c238a8 --- /dev/null +++ b/event/socks5.h @@ -0,0 +1,71 @@ +#ifndef HV_SOCKS5_H_ +#define HV_SOCKS5_H_ + +// SOCKS5 client proxy support (RFC 1928 + RFC 1929 username/password auth). +// +// This is used internally by hio_connect() when hio_set_socks5() has been +// called: instead of connecting to the target directly, the io connects to the +// SOCKS5 proxy and runs a CONNECT handshake to the original target (sent as a +// domain name so the proxy resolves it). See hio_set_socks5() in hloop.h. + +#include "hexport.h" + +#define SOCKS5_VERSION 0x05 +#define SOCKS5_AUTH_VERSION 0x01 // username/password auth subnegotiation + +// auth methods +#define SOCKS5_AUTH_NONE 0x00 +#define SOCKS5_AUTH_USERPASS 0x02 +#define SOCKS5_AUTH_NOACCEPT 0xFF + +// commands +#define SOCKS5_CMD_CONNECT 0x01 + +// address types +#define SOCKS5_ATYP_IPV4 0x01 +#define SOCKS5_ATYP_DOMAIN 0x03 +#define SOCKS5_ATYP_IPV6 0x04 + +// reply codes (0x00 = success) +#define SOCKS5_REP_SUCCESS 0x00 + +// User-facing SOCKS5 proxy configuration (like unpack_setting_t / +// reconn_setting_t). Passed to hio_set_socks5(); the value is copied, so a +// stack variable is fine. +typedef struct socks5_setting_s { + char host[256]; // proxy host + int port; // proxy port + char username[256]; // empty => no auth + char password[256]; + +#ifdef __cplusplus + socks5_setting_s() { + host[0] = '\0'; + port = 0; + username[0] = '\0'; + password[0] = '\0'; + } +#endif +} socks5_setting_t; + +// Internal per-connection runtime state for the SOCKS5 handshake (held on +// hio_t). Not part of the public configuration. +typedef struct socks5_conn_s { + socks5_setting_t setting; // copied proxy config + char target_host[256]; // address the proxy should CONNECT to + int target_port; + int state; // socks5_state_e (see nio.c) +} socks5_conn_t; + +BEGIN_EXTERN_C + +// Build SOCKS5 handshake messages into buf; return bytes written (<0 on error). +HV_EXPORT int socks5_build_method_request (const socks5_conn_t* s5, unsigned char* buf); +HV_EXPORT int socks5_build_auth_request (const socks5_conn_t* s5, unsigned char* buf); +HV_EXPORT int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf); +// Expected CONNECT reply length for a fixed-size ATYP (ipv4/ipv6); -1 otherwise. +HV_EXPORT int socks5_connect_reply_len(unsigned char atyp); + +END_EXTERN_C + +#endif // HV_SOCKS5_H_ diff --git a/evpp/Channel.h b/evpp/Channel.h index 823226224..77836a856 100644 --- a/evpp/Channel.h +++ b/evpp/Channel.h @@ -8,6 +8,7 @@ #include "hloop.h" #include "hsocket.h" +#include "socks5.h" // socks5_setting_t #include "Buffer.h" @@ -286,6 +287,12 @@ class SocketChannel : public Channel { return hio_set_hostname(io_, hostname.c_str()); } + // SOCKS5 proxy (client side); see hio_set_socks5. + int setSocks5Proxy(socks5_setting_t* setting) { + if (io_ == NULL) return -1; + return hio_set_socks5(io_, setting); + } + // timeout void setConnectTimeout(int timeout_ms) { if (io_ == NULL) return; diff --git a/evpp/TcpClient.h b/evpp/TcpClient.h index 31d1f6669..c72932017 100644 --- a/evpp/TcpClient.h +++ b/evpp/TcpClient.h @@ -28,6 +28,7 @@ class TcpClientEventLoopTmpl { tls_setting = NULL; reconn_setting = NULL; unpack_setting = NULL; + socks5_setting = NULL; reconn_timer_id = INVALID_TIMER_ID; dns_id = INVALID_DNS_ID; reconn_success_cnt_ = 0; @@ -40,6 +41,7 @@ class TcpClientEventLoopTmpl { HV_FREE(tls_setting); HV_FREE(reconn_setting); HV_FREE(unpack_setting); + HV_FREE(socks5_setting); } const EventLoopPtr& loop() { @@ -141,6 +143,14 @@ class TcpClientEventLoopTmpl { int startConnect() { loop_->assertInLoopThread(); + // With a SOCKS5 proxy, the target host is resolved by the proxy, so skip + // client-side DNS: connect to the proxy and pass the target as a domain. + // remote_addr only needs a valid family for socket(); the proxy uses the + // hostname (set as SNI/target below), not remote_addr, to reach the target. + if (socks5_setting && remote_port >= 0 && + !remote_host.empty() && !is_ipaddr(remote_host.c_str())) { + return startConnectWithAddr(); + } // If the target is a hostname, resolve it asynchronously through hdns // so the event loop is never blocked by getaddrinfo. This covers both // the first connect and every reconnect (to pick up DNS changes). @@ -233,6 +243,14 @@ class TcpClientEventLoopTmpl { int startConnectWithAddr() { loop_->assertInLoopThread(); + // SOCKS5 + hostname target: DNS is skipped (the proxy resolves the + // target), so remote_addr has no family yet. Give it one (+ the target + // port) so socket() works and the io layer captures the correct target + // port; the actual connect is repointed to the proxy in hio_connect. + if (socks5_setting && remote_addr.sa.sa_family == 0 && remote_port >= 0) { + remote_addr.sin.sin_family = AF_INET; + sockaddr_set_port(&remote_addr, remote_port); + } if (channel == NULL || channel->isClosed()) { int connfd = createsocket(&remote_addr.sa); if (connfd < 0) { @@ -246,6 +264,14 @@ class TcpClientEventLoopTmpl { if (connect_timeout) { channel->setConnectTimeout(connect_timeout); } + // SOCKS5 proxy: record the proxy + set the target host as the io + // hostname so the handshake sends CONNECT : (domain). + if (socks5_setting) { + if (!remote_host.empty() && !is_ipaddr(remote_host.c_str())) { + channel->setHostname(remote_host); + } + channel->setSocks5Proxy(socks5_setting); + } if (tls) { channel->enableSSL(); if (tls_setting) { @@ -353,6 +379,21 @@ class TcpClientEventLoopTmpl { connect_timeout = ms; } + // SOCKS5 proxy: route the connection through a SOCKS5 proxy. The target + // host is sent to the proxy as a domain name (the proxy resolves it), so + // client-side DNS is skipped when a hostname target is used with a proxy. + // The setting is copied; pass a username/password for auth (see socks5_setting_t). + void setSocks5Proxy(socks5_setting_t* setting) { + if (setting == NULL) { + HV_FREE(socks5_setting); + return; + } + if (socks5_setting == NULL) { + HV_ALLOC_SIZEOF(socks5_setting); + } + *socks5_setting = *setting; + } + void setReconnect(reconn_setting_t* setting) { if (setting == NULL) { cancelReconnectTimer(); @@ -419,6 +460,8 @@ class TcpClientEventLoopTmpl { hssl_ctx_opt_t* tls_setting; reconn_setting_t* reconn_setting; unpack_setting_t* unpack_setting; + // socks5 proxy (client side), applied in startConnectWithAddr + socks5_setting_t* socks5_setting; // Callback std::function onConnection; diff --git a/examples/socks5_client_test.cpp b/examples/socks5_client_test.cpp new file mode 100644 index 000000000..cb25be514 --- /dev/null +++ b/examples/socks5_client_test.cpp @@ -0,0 +1,63 @@ +/* + * TCP client via SOCKS5 proxy. + * + * Demonstrates routing a TcpClient connection through a SOCKS5 proxy at the io + * layer (hio_set_socks5). The target host is sent to the proxy as a domain name + * (the proxy resolves it). + * + * @build make examples + * @test # start libhv's own SOCKS5 proxy server as the proxy: + * bin/socks5_proxy_server 1080 + * # then connect to an echo server through it: + * bin/tcp_echo_server 1234 + * bin/socks5_client_test 127.0.0.1 1080 127.0.0.1 1234 + * + * @example bin/socks5_client_test + */ + +#include "TcpClient.h" + +using namespace hv; + +int main(int argc, char** argv) { + if (argc < 5) { + printf("Usage: %s proxy_host proxy_port target_host target_port [user] [pass]\n", argv[0]); + return -1; + } + const char* proxy_host = argv[1]; + int proxy_port = atoi(argv[2]); + const char* target_host = argv[3]; + int target_port = atoi(argv[4]); + const char* user = argc > 5 ? argv[5] : NULL; + const char* pass = argc > 6 ? argv[6] : NULL; + + TcpClient cli; + int connfd = cli.createsocket(target_port, target_host); + if (connfd < 0) { + printf("createsocket failed\n"); + return -1; + } + // route through the SOCKS5 proxy + socks5_setting_t socks5; + hv_strncpy(socks5.host, proxy_host, sizeof(socks5.host)); + socks5.port = proxy_port; + if (user) hv_strncpy(socks5.username, user, sizeof(socks5.username)); + if (pass) hv_strncpy(socks5.password, pass, sizeof(socks5.password)); + cli.setSocks5Proxy(&socks5); + + cli.onConnection = [](const SocketChannelPtr& channel) { + if (channel->isConnected()) { + printf("connected through socks5 proxy, send hello\n"); + channel->write("hello via socks5\n"); + } else { + printf("disconnected\n"); + } + }; + cli.onMessage = [](const SocketChannelPtr& channel, Buffer* buf) { + printf("recv: %.*s", (int)buf->size(), (char*)buf->data()); + }; + + cli.start(); + while (1) hv_sleep(1); + return 0; +} From 25f32e43a3ee471da252345ea8742f1b9e9760c5 Mon Sep 17 00:00:00 2001 From: ithewei Date: Sun, 20 Sep 2026 23:29:57 +0800 Subject: [PATCH 02/12] fix(socks5): address review findings - 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 --- BUILD.bazel | 1 - Makefile.vars | 1 - cmake/vars.cmake | 1 - docs/cn/socks5.md | 11 +++++++++-- event/hloop.h | 17 +++++++++++++++-- event/nio.c | 32 ++++++++++++++++++++++++++------ event/socks5.h | 38 +++++++++----------------------------- evpp/Channel.h | 1 - evpp/TcpClient.h | 8 +++++--- examples/CMakeLists.txt | 6 +++++- 10 files changed, 69 insertions(+), 47 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 578d9ef8b..1a229bde3 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -295,7 +295,6 @@ EVENT_HEADERS = [ "event/hloop.h", "event/nlog.h", "event/hdns.h", - "event/socks5.h", ] UTIL_HEADERS = [ diff --git a/Makefile.vars b/Makefile.vars index a54d47d44..f51418a97 100644 --- a/Makefile.vars +++ b/Makefile.vars @@ -47,7 +47,6 @@ SSL_HEADERS = ssl/hssl.h EVENT_HEADERS = event/hloop.h\ event/nlog.h\ event/hdns.h\ - event/socks5.h\ UTIL_HEADERS = util/base64.h\ util/md5.h\ diff --git a/cmake/vars.cmake b/cmake/vars.cmake index dfa38c121..83e7afd7f 100644 --- a/cmake/vars.cmake +++ b/cmake/vars.cmake @@ -28,7 +28,6 @@ set(EVENT_HEADERS event/hloop.h event/nlog.h event/hdns.h - event/socks5.h ) set(UTIL_HEADERS diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md index a8a498bec..47301c9e6 100644 --- a/docs/cn/socks5.md +++ b/docs/cn/socks5.md @@ -13,6 +13,10 @@ SOCKS5 代理客户端 ## 配置结构 socks5_setting_t +```c +typedef struct socks5_setting_s { +## 配置结构 socks5_setting_t + ```c typedef struct socks5_setting_s { char host[256]; // 代理主机 @@ -22,6 +26,9 @@ typedef struct socks5_setting_s { } socks5_setting_t; ``` +> C 用户使用前请先清零:`socks5_setting_t s5; memset(&s5, 0, sizeof(s5));`(或 `= {0}`), +> 否则 username/password 为未初始化值会导致认证方式误判。C++ 有默认构造,无需手动清零。 + ## C 接口 ```c @@ -34,8 +41,8 @@ int hio_set_socks5(hio_t* io, socks5_setting_t* setting); ```c++ namespace hv { -// Channel / SocketChannel -int Channel::setSocks5Proxy(socks5_setting_t* setting); +// SocketChannel +int SocketChannel::setSocks5Proxy(socks5_setting_t* setting); // TcpClient void TcpClient::setSocks5Proxy(socks5_setting_t* setting); diff --git a/event/hloop.h b/event/hloop.h index f8d90dbbb..c142baaf2 100644 --- a/event/hloop.h +++ b/event/hloop.h @@ -353,8 +353,21 @@ HV_EXPORT const char* hio_get_hostname(hio_t* io); // HttpClient, ...) can use it. The setting is copied. Pass an empty username // for no auth, or a username/password for RFC 1929 auth. // NOTE: set before hio_connect(). -struct socks5_setting_s; -HV_EXPORT int hio_set_socks5(hio_t* io, struct socks5_setting_s* setting); +typedef struct socks5_setting_s { + char host[256]; // proxy host + int port; // proxy port + char username[256]; // empty => no auth + char password[256]; +#ifdef __cplusplus + socks5_setting_s() { + host[0] = '\0'; + port = 0; + username[0] = '\0'; + password[0] = '\0'; + } +#endif +} socks5_setting_t; +HV_EXPORT int hio_set_socks5(hio_t* io, socks5_setting_t* setting); // connect timeout => hclose_cb HV_EXPORT void hio_set_connect_timeout(hio_t* io, int timeout_ms DEFAULT(HIO_DEFAULT_CONNECT_TIMEOUT)); diff --git a/event/nio.c b/event/nio.c index b71e48a10..ff3ce9a1d 100644 --- a/event/nio.c +++ b/event/nio.c @@ -251,7 +251,8 @@ enum socks5_state_e { static void socks5_handshake(hio_t* io) { socks5_conn_t* s5 = io->socks5; - unsigned char buf[512]; + // large enough for the auth request: 1+1+255+1+255 = 513 (RFC 1929 max) + unsigned char buf[640]; int n; switch (s5->state) { @@ -601,20 +602,24 @@ int hio_accept(hio_t* io) { } int hio_connect(hio_t* io) { - // SOCKS5: the target was recorded in io (peeraddr/hostname) by - // hio_create_socket; redirect the actual TCP connect to the proxy while - // keeping the target for the CONNECT request (sent as a domain name). + // SOCKS5: connect to the proxy instead of the target. The target was + // recorded on io (peeraddr/hostname) by hio_create_socket; capture it for + // the CONNECT request (sent as a domain name), then point the connection at + // the proxy. The listening socket was created with the target's address + // family, but the proxy may resolve to a different family, so recreate the + // fd with the proxy family when they differ (otherwise connect() fails with + // EAFNOSUPPORT). if (io->socks5) { socks5_conn_t* s5 = io->socks5; // capture target: prefer the SNI hostname (original domain), else the - // numeric peer address. + // numeric peer address; port always comes from peeraddr. if (io->hostname && io->hostname[0]) { hv_strncpy(s5->target_host, io->hostname, sizeof(s5->target_host)); } else { sockaddr_ip((sockaddr_u*)io->peeraddr, s5->target_host, sizeof(s5->target_host)); } s5->target_port = sockaddr_port((sockaddr_u*)io->peeraddr); - // repoint peeraddr to the proxy + // resolve the proxy address sockaddr_u proxyaddr; memset(&proxyaddr, 0, sizeof(proxyaddr)); if (sockaddr_set_ipport(&proxyaddr, s5->setting.host, s5->setting.port) != 0) { @@ -622,6 +627,21 @@ int hio_connect(hio_t* io) { hio_close_async(io); return -1; } + // recreate the socket with the proxy family if it differs from the + // target family the socket was created with. + if (proxyaddr.sa.sa_family != io->peeraddr->sa_family) { + int newfd = socket(proxyaddr.sa.sa_family, SOCK_STREAM, 0); + if (newfd < 0) { + io->error = socket_errno(); + hio_close_async(io); + return -1; + } + nonblocking(newfd); + hio_detach(io); // remove from loop->ios[oldfd] + closesocket(io->fd); + io->fd = newfd; + hio_attach(io->loop, io); // re-key by newfd (handles resize) + } hio_set_peeraddr(io, &proxyaddr.sa, sockaddr_len(&proxyaddr)); } int ret = connect(io->fd, io->peeraddr, SOCKADDR_LEN(io->peeraddr)); diff --git a/event/socks5.h b/event/socks5.h index 955c238a8..1c75ee905 100644 --- a/event/socks5.h +++ b/event/socks5.h @@ -1,14 +1,13 @@ #ifndef HV_SOCKS5_H_ #define HV_SOCKS5_H_ -// SOCKS5 client proxy support (RFC 1928 + RFC 1929 username/password auth). +// Internal SOCKS5 client helpers (RFC 1928 + RFC 1929 username/password auth). // -// This is used internally by hio_connect() when hio_set_socks5() has been -// called: instead of connecting to the target directly, the io connects to the -// SOCKS5 proxy and runs a CONNECT handshake to the original target (sent as a -// domain name so the proxy resolves it). See hio_set_socks5() in hloop.h. +// NOTE: this is an internal header (not installed). The public API is +// socks5_setting_t + hio_set_socks5() in hloop.h. Used internally by +// hio_connect() to run the proxy handshake; see nio.c. -#include "hexport.h" +#include "hloop.h" // socks5_setting_t #define SOCKS5_VERSION 0x05 #define SOCKS5_AUTH_VERSION 0x01 // username/password auth subnegotiation @@ -29,25 +28,6 @@ // reply codes (0x00 = success) #define SOCKS5_REP_SUCCESS 0x00 -// User-facing SOCKS5 proxy configuration (like unpack_setting_t / -// reconn_setting_t). Passed to hio_set_socks5(); the value is copied, so a -// stack variable is fine. -typedef struct socks5_setting_s { - char host[256]; // proxy host - int port; // proxy port - char username[256]; // empty => no auth - char password[256]; - -#ifdef __cplusplus - socks5_setting_s() { - host[0] = '\0'; - port = 0; - username[0] = '\0'; - password[0] = '\0'; - } -#endif -} socks5_setting_t; - // Internal per-connection runtime state for the SOCKS5 handshake (held on // hio_t). Not part of the public configuration. typedef struct socks5_conn_s { @@ -60,11 +40,11 @@ typedef struct socks5_conn_s { BEGIN_EXTERN_C // Build SOCKS5 handshake messages into buf; return bytes written (<0 on error). -HV_EXPORT int socks5_build_method_request (const socks5_conn_t* s5, unsigned char* buf); -HV_EXPORT int socks5_build_auth_request (const socks5_conn_t* s5, unsigned char* buf); -HV_EXPORT int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf); +int socks5_build_method_request (const socks5_conn_t* s5, unsigned char* buf); +int socks5_build_auth_request (const socks5_conn_t* s5, unsigned char* buf); +int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf); // Expected CONNECT reply length for a fixed-size ATYP (ipv4/ipv6); -1 otherwise. -HV_EXPORT int socks5_connect_reply_len(unsigned char atyp); +int socks5_connect_reply_len(unsigned char atyp); END_EXTERN_C diff --git a/evpp/Channel.h b/evpp/Channel.h index 77836a856..5b11d35f8 100644 --- a/evpp/Channel.h +++ b/evpp/Channel.h @@ -8,7 +8,6 @@ #include "hloop.h" #include "hsocket.h" -#include "socks5.h" // socks5_setting_t #include "Buffer.h" diff --git a/evpp/TcpClient.h b/evpp/TcpClient.h index c72932017..171b30b01 100644 --- a/evpp/TcpClient.h +++ b/evpp/TcpClient.h @@ -244,9 +244,11 @@ class TcpClientEventLoopTmpl { int startConnectWithAddr() { loop_->assertInLoopThread(); // SOCKS5 + hostname target: DNS is skipped (the proxy resolves the - // target), so remote_addr has no family yet. Give it one (+ the target - // port) so socket() works and the io layer captures the correct target - // port; the actual connect is repointed to the proxy in hio_connect. + // target), so remote_addr has no family yet. Give it a placeholder + // family (+ the target port) so socket()/createsocket works and the io + // layer captures the target port. hio_connect() then dials the proxy + // and recreates the fd with the proxy's family if it differs, so this + // placeholder family does not matter. if (socks5_setting && remote_addr.sa.sa_family == 0 && remote_port >= 0) { remote_addr.sin.sin_family = AF_INET; sockaddr_set_port(&remote_addr, remote_port); diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 45c02c2a9..ed72d457c 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -110,7 +110,11 @@ if(WITH_EVPP) target_compile_definitions(nmap PRIVATE PRINT_DEBUG) target_link_libraries(nmap ${HV_LIBRARIES}) - list(APPEND EXAMPLES hmain_test nmap) + # socks5_client_test + add_executable(socks5_client_test socks5_client_test.cpp) + target_link_libraries(socks5_client_test ${HV_LIBRARIES}) + + list(APPEND EXAMPLES hmain_test nmap socks5_client_test) if(WITH_REDIS) include_directories(../redis) From 988d6f180be093ec3b9dc3d82f2424242998e584 Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 01:45:16 +0800 Subject: [PATCH 03/12] fix(socks5): robust fragmented handshake + correct ATYP for IP literals 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 --- event/nio.c | 212 +++++++++++++++++++++++++++++-------------------- event/socks5.c | 41 +++++----- event/socks5.h | 7 +- 3 files changed, 151 insertions(+), 109 deletions(-) diff --git a/event/nio.c b/event/nio.c index ff3ce9a1d..09c6e081e 100644 --- a/event/nio.c +++ b/event/nio.c @@ -237,114 +237,151 @@ static void nio_connect_established(hio_t* io) { } // SOCKS5 client handshake state machine (RFC 1928 + RFC 1929). -// Driven non-blockingly via hio_add(io, socks5_handshake, HV_READ/WRITE), -// mirroring ssl_client_handshake. Runs on the raw TCP socket to the proxy. +// Driven via hio_add(io, socks5_handshake, HV_READ), exactly like +// ssl_client_handshake: it does raw recv() into an internal accumulator and +// does NOT touch io->read_cb (which the upper-layer Channel owns for delivering +// user data). Bytes are buffered in s5->rbuf until a full step is available, so +// the handshake is robust to TCP fragmentation. Runs before the optional SSL +// handshake. enum socks5_state_e { - S5_SEND_METHODS = 0, - S5_RECV_METHOD, - S5_SEND_AUTH, - S5_RECV_AUTH, - S5_SEND_CONNECT, - S5_RECV_REPLY, - S5_DONE, + S5_RECV_METHOD = 0, // 2 bytes: VER METHOD + S5_RECV_AUTH, // 2 bytes: VER STATUS + S5_RECV_REPLY_HEAD, // 4 bytes: VER REP RSV ATYP + S5_RECV_REPLY_ADDR, // fixed addr+port (ipv4/ipv6) + S5_RECV_REPLY_DADDR, // 1 (dlen) already known: domain + port }; -static void socks5_handshake(hio_t* io) { +static void socks5_handshake(hio_t* io); + +static void socks5_fail(hio_t* io) { + if (io->error == 0) io->error = ERR_CONNECT; + hlogw("connfd=%d socks5 handshake error", io->fd); + hio_close(io); +} + +// advance to a new state that needs `want` more bytes, resetting the buffer. +static void socks5_expect(hio_t* io, int state, int want) { socks5_conn_t* s5 = io->socks5; - // large enough for the auth request: 1+1+255+1+255 = 513 (RFC 1929 max) - unsigned char buf[640]; - int n; + s5->state = state; + s5->rlen = 0; + s5->want = want; +} - switch (s5->state) { - case S5_SEND_METHODS: - n = socks5_build_method_request(s5, buf); - if (send(io->fd, (const char*)buf, n, 0) != n) goto s5_error; - s5->state = S5_RECV_METHOD; - hio_add(io, socks5_handshake, HV_READ); - return; +// send the SOCKS5 CONNECT request and wait for the 4-byte reply header. +static void socks5_send_connect(hio_t* io) { + socks5_conn_t* s5 = io->socks5; + unsigned char buf[300]; + int n = socks5_build_connect_request(s5, buf); + if (n < 0) { socks5_fail(io); return; } + if (hio_write(io, buf, n) < 0) { socks5_fail(io); return; } + socks5_expect(io, S5_RECV_REPLY_HEAD, 4); +} + +// hand off the established proxy tunnel to the upper layer: stop the handshake +// read handler, then run the SSL handshake / connect_cb. io->read_cb was never +// touched, so the upper-layer Channel read callback stays intact. +static void socks5_established(hio_t* io) { + hio_del(io, HV_READ); + nio_connect_established(io); +} + +// process one accumulated step; s5->rbuf holds exactly s5->want bytes. +static void socks5_dispatch(hio_t* io) { + socks5_conn_t* s5 = io->socks5; + unsigned char* buf = s5->rbuf; - case S5_RECV_METHOD: { - // reply: VER METHOD (2 bytes) - n = recv(io->fd, (char*)buf, 2, 0); - if (n == 0) goto s5_error; // peer closed - if (n < 0) { if (socket_errno()==EAGAIN||socket_errno()==EINTR) return; goto s5_error; } - if (n < 2 || buf[0] != SOCKS5_VERSION) goto s5_error; - unsigned char method = buf[1]; - if (method == SOCKS5_AUTH_NONE) { - s5->state = S5_SEND_CONNECT; - } else if (method == SOCKS5_AUTH_USERPASS && s5->setting.username[0]) { - s5->state = S5_SEND_AUTH; + switch (s5->state) { + case S5_RECV_METHOD: + // VER METHOD + if (buf[0] != SOCKS5_VERSION) { socks5_fail(io); return; } + if (buf[1] == SOCKS5_AUTH_NONE) { + socks5_send_connect(io); + } else if (buf[1] == SOCKS5_AUTH_USERPASS && s5->setting.username[0]) { + unsigned char req[640]; + int n = socks5_build_auth_request(s5, req); + if (hio_write(io, req, n) < 0) { socks5_fail(io); return; } + socks5_expect(io, S5_RECV_AUTH, 2); } else { - goto s5_error; // no acceptable method + socks5_fail(io); // no acceptable method } - hio_del(io, HV_READ); - socks5_handshake(io); // advance immediately (send) return; - } - case S5_SEND_AUTH: - n = socks5_build_auth_request(s5, buf); - if (send(io->fd, (const char*)buf, n, 0) != n) goto s5_error; - s5->state = S5_RECV_AUTH; - hio_add(io, socks5_handshake, HV_READ); + case S5_RECV_AUTH: + // VER STATUS (0 == success) + if (buf[1] != 0x00) { socks5_fail(io); return; } + socks5_send_connect(io); return; - case S5_RECV_AUTH: - // reply: VER STATUS (2 bytes), STATUS 0 = success - n = recv(io->fd, (char*)buf, 2, 0); - if (n == 0) goto s5_error; - if (n < 0) { if (socket_errno()==EAGAIN||socket_errno()==EINTR) return; goto s5_error; } - if (n < 2 || buf[1] != 0x00) goto s5_error; - s5->state = S5_SEND_CONNECT; - hio_del(io, HV_READ); - socks5_handshake(io); + case S5_RECV_REPLY_HEAD: { + // VER REP RSV ATYP + if (buf[0] != SOCKS5_VERSION) { socks5_fail(io); return; } + if (buf[1] != SOCKS5_REP_SUCCESS) { io->error = ERR_CONNECT; socks5_fail(io); return; } + unsigned char atyp = buf[3]; + if (atyp == SOCKS5_ATYP_IPV4) { + socks5_expect(io, S5_RECV_REPLY_ADDR, 4 + 2); // addr + port + } else if (atyp == SOCKS5_ATYP_IPV6) { + socks5_expect(io, S5_RECV_REPLY_ADDR, 16 + 2); + } else if (atyp == SOCKS5_ATYP_DOMAIN) { + // read 1 length byte + then domain+port; do it in one extra step by + // first requiring the length byte. + socks5_expect(io, S5_RECV_REPLY_DADDR, 1); + } else { + socks5_fail(io); + } return; + } - case S5_SEND_CONNECT: - n = socks5_build_connect_request(s5, buf); - if (n < 0 || send(io->fd, (const char*)buf, n, 0) != n) goto s5_error; - s5->state = S5_RECV_REPLY; - hio_add(io, socks5_handshake, HV_READ); + case S5_RECV_REPLY_ADDR: + // bound addr+port consumed; tunnel is up + socks5_established(io); return; - case S5_RECV_REPLY: { - // reply: VER REP RSV ATYP BND.ADDR BND.PORT - // Peek the fixed 4-byte header first to learn ATYP, then drain the - // variable-length bound address so the stream starts clean. - n = recv(io->fd, (char*)buf, 4, MSG_PEEK); - if (n == 0) goto s5_error; - if (n < 0) { if (socket_errno()==EAGAIN||socket_errno()==EINTR) return; goto s5_error; } - if (n < 4) return; // wait for the full header - if (buf[0] != SOCKS5_VERSION) goto s5_error; - if (buf[1] != SOCKS5_REP_SUCCESS) { io->error = ERR_CONNECT; goto s5_error; } - unsigned char atyp = buf[3]; - int total; - if (atyp == SOCKS5_ATYP_IPV4) total = 4 + 4 + 2; - else if (atyp == SOCKS5_ATYP_IPV6) total = 4 + 16 + 2; - else if (atyp == SOCKS5_ATYP_DOMAIN) { - unsigned char hdr[5]; - if (recv(io->fd, (char*)hdr, 5, MSG_PEEK) < 5) return; // need len byte - total = 4 + 1 + hdr[4] + 2; - } else goto s5_error; - // ensure the whole reply is available, then consume it - n = recv(io->fd, (char*)buf, total, MSG_PEEK); - if (n < total) return; // wait for more - recv(io->fd, (char*)buf, total, 0); // drain - s5->state = S5_DONE; - hio_del(io, HV_READ); - // proxy tunnel established -> proceed to SSL handshake / connect_cb - nio_connect_established(io); + case S5_RECV_REPLY_DADDR: + // first entry: we have the 1-byte domain length -> need dlen + 2 more. + // Re-enter with the full length once available. + if (s5->want == 1) { + int dlen = buf[0]; + socks5_expect(io, S5_RECV_REPLY_DADDR, dlen + 2); + return; + } + socks5_established(io); return; - } default: + socks5_fail(io); return; } +} -s5_error: - if (io->error == 0) io->error = ERR_CONNECT; - hlogw("connfd=%d socks5 handshake error", io->fd); - hio_close(io); +// hio_add read handler: accumulate into s5->rbuf until s5->want bytes are +// available, then dispatch. Never touches io->read_cb. +static void socks5_handshake(hio_t* io) { + socks5_conn_t* s5 = io->socks5; + while (s5->rlen < s5->want) { + int need = s5->want - s5->rlen; + if (s5->want > (int)sizeof(s5->rbuf)) { socks5_fail(io); return; } + int n = recv(io->fd, (char*)s5->rbuf + s5->rlen, need, 0); + if (n == 0) { socks5_fail(io); return; } // peer closed + if (n < 0) { + int err = socket_errno(); + if (err == EAGAIN || err == EINTR) return; // wait for more + io->error = err; + socks5_fail(io); + return; + } + s5->rlen += n; + } + socks5_dispatch(io); +} + +// Kick off the SOCKS5 handshake once the TCP connection to the proxy is up. +static void socks5_handshake_start(hio_t* io) { + socks5_conn_t* s5 = io->socks5; + unsigned char buf[8]; + int n = socks5_build_method_request(s5, buf); + if (hio_write(io, buf, n) < 0) { socks5_fail(io); return; } + socks5_expect(io, S5_RECV_METHOD, 2); + hio_add(io, socks5_handshake, HV_READ); } static void nio_connect(hio_t* io) { @@ -362,8 +399,7 @@ static void nio_connect(hio_t* io) { // SOCKS5: the TCP connection is to the proxy; run the proxy handshake // (CONNECT to the real target) before SSL / connect_cb. if (io->socks5) { - io->socks5->state = S5_SEND_METHODS; - socks5_handshake(io); + socks5_handshake_start(io); return; } diff --git a/event/socks5.c b/event/socks5.c index 2bf364ae7..ca42c59bf 100644 --- a/event/socks5.c +++ b/event/socks5.c @@ -2,6 +2,8 @@ #include +#include "hsocket.h" // is_ipv4 / is_ipv6 / inet_pton via hplatform + // Build the SOCKS5 method-selection request. // +----+----------+----------+ // |VER | NMETHODS | METHODS | @@ -38,35 +40,36 @@ int socks5_build_auth_request(const socks5_conn_t* s5, unsigned char* buf) { return n; } -// Build a CONNECT request using ATYP=domain (the proxy resolves the target). +// Build a CONNECT request. // +----+-----+-------+------+----------+----------+ // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | // +----+-----+-------+------+----------+----------+ -// Returns bytes written, or -1 if the target host is too long. +// An IPv4/IPv6 literal target is encoded as ATYP=1/4 (raw address bytes, per +// RFC 1928); anything else is sent as ATYP=domain so the proxy resolves it. +// Returns bytes written, or -1 on error (host too long). int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf) { - int hlen = (int)strlen(s5->target_host); - if (hlen <= 0 || hlen > 255) return -1; int n = 0; buf[n++] = SOCKS5_VERSION; buf[n++] = SOCKS5_CMD_CONNECT; buf[n++] = 0x00; // RSV - buf[n++] = SOCKS5_ATYP_DOMAIN; - buf[n++] = (unsigned char)hlen; - memcpy(buf + n, s5->target_host, hlen); n += hlen; + + struct in_addr addr4; + struct in6_addr addr6; + if (inet_pton(AF_INET, s5->target_host, &addr4) == 1) { + buf[n++] = SOCKS5_ATYP_IPV4; + memcpy(buf + n, &addr4, 4); n += 4; + } else if (inet_pton(AF_INET6, s5->target_host, &addr6) == 1) { + buf[n++] = SOCKS5_ATYP_IPV6; + memcpy(buf + n, &addr6, 16); n += 16; + } else { + int hlen = (int)strlen(s5->target_host); + if (hlen <= 0 || hlen > 255) return -1; + buf[n++] = SOCKS5_ATYP_DOMAIN; + buf[n++] = (unsigned char)hlen; + memcpy(buf + n, s5->target_host, hlen); n += hlen; + } unsigned short port = (unsigned short)s5->target_port; buf[n++] = (unsigned char)((port >> 8) & 0xFF); buf[n++] = (unsigned char)(port & 0xFF); return n; } - -// The CONNECT reply's bound-address section is variable-length by ATYP; return -// the total expected reply length for the given atyp, or -1 if unknown. -// Fixed part is 4 bytes (VER REP RSV ATYP) + addr + 2 (port). -int socks5_connect_reply_len(unsigned char atyp) { - switch (atyp) { - case SOCKS5_ATYP_IPV4: return 4 + 4 + 2; - case SOCKS5_ATYP_IPV6: return 4 + 16 + 2; - case SOCKS5_ATYP_DOMAIN: return -1; // needs the length byte, handled by caller - default: return -1; - } -} diff --git a/event/socks5.h b/event/socks5.h index 1c75ee905..10ec81540 100644 --- a/event/socks5.h +++ b/event/socks5.h @@ -35,6 +35,11 @@ typedef struct socks5_conn_s { char target_host[256]; // address the proxy should CONNECT to int target_port; int state; // socks5_state_e (see nio.c) + // handshake read accumulator: SOCKS5 replies may be fragmented across TCP + // segments, so bytes are buffered here until a full message is available. + unsigned char rbuf[300]; // max reply: 4 + 1 + 255 + 2 (domain bind) + int rlen; // bytes currently in rbuf + int want; // bytes needed to complete the current step } socks5_conn_t; BEGIN_EXTERN_C @@ -43,8 +48,6 @@ BEGIN_EXTERN_C int socks5_build_method_request (const socks5_conn_t* s5, unsigned char* buf); int socks5_build_auth_request (const socks5_conn_t* s5, unsigned char* buf); int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf); -// Expected CONNECT reply length for a fixed-size ATYP (ipv4/ipv6); -1 otherwise. -int socks5_connect_reply_len(unsigned char atyp); END_EXTERN_C From 75f56668ae3f101de946d2453fb7c5300d1b1c71 Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 02:08:28 +0800 Subject: [PATCH 04/12] fix(socks5): use raw send for handshake, add bazel example + docs - 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 --- docs/cn/socks5.md | 5 +---- event/nio.c | 24 +++++++++++++++++++++--- examples/BUILD.bazel | 12 +++++++++++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md index 47301c9e6..175574ccc 100644 --- a/docs/cn/socks5.md +++ b/docs/cn/socks5.md @@ -10,13 +10,10 @@ SOCKS5 代理客户端 > - 只做客户端代理(通过代理连出去),服务端见 [examples/socks5_proxy_server.c](../../examples/socks5_proxy_server.c)。 > - 支持无认证与用户名/密码认证(不支持 GSSAPI)。 > - 目标地址以域名(ATYP=domain)发送给代理解析,因此配了代理时客户端本地不再做 DNS。 +> - `host` 建议直接填代理的 IP。若填域名,`hio_connect()` 会在事件循环线程内同步解析代理地址(getaddrinfo),首连及每次重连都可能短暂阻塞该 loop。 ## 配置结构 socks5_setting_t -```c -typedef struct socks5_setting_s { -## 配置结构 socks5_setting_t - ```c typedef struct socks5_setting_s { char host[256]; // 代理主机 diff --git a/event/nio.c b/event/nio.c index 09c6e081e..e005078ae 100644 --- a/event/nio.c +++ b/event/nio.c @@ -267,13 +267,31 @@ static void socks5_expect(hio_t* io, int state, int want) { s5->want = want; } +// Raw handshake send. The SOCKS5 handshake runs immediately after the TCP +// connection to the proxy is established, when the socket send buffer is empty +// and the messages are tiny (<= 513 bytes), so a short write is not expected. +// We deliberately do NOT use hio_write() here: it would invoke the upper-layer +// write_cb (leaking handshake bytes, including credentials, to the application +// before onConnection), dispatch to hssl_write() with a not-yet-created SSL +// handle for a TLS target, and enqueue on EAGAIN via hio_add() which would +// clobber the handshake read handler. A short write or error is treated as +// fatal and closes the connection. +static int socks5_send(hio_t* io, const void* buf, int len) { + int flag = 0; +#ifdef MSG_NOSIGNAL + flag |= MSG_NOSIGNAL; +#endif + int n = send(io->fd, (const char*)buf, len, flag); + return n == len ? 0 : -1; +} + // send the SOCKS5 CONNECT request and wait for the 4-byte reply header. static void socks5_send_connect(hio_t* io) { socks5_conn_t* s5 = io->socks5; unsigned char buf[300]; int n = socks5_build_connect_request(s5, buf); if (n < 0) { socks5_fail(io); return; } - if (hio_write(io, buf, n) < 0) { socks5_fail(io); return; } + if (socks5_send(io, buf, n) != 0) { socks5_fail(io); return; } socks5_expect(io, S5_RECV_REPLY_HEAD, 4); } @@ -299,7 +317,7 @@ static void socks5_dispatch(hio_t* io) { } else if (buf[1] == SOCKS5_AUTH_USERPASS && s5->setting.username[0]) { unsigned char req[640]; int n = socks5_build_auth_request(s5, req); - if (hio_write(io, req, n) < 0) { socks5_fail(io); return; } + if (socks5_send(io, req, n) != 0) { socks5_fail(io); return; } socks5_expect(io, S5_RECV_AUTH, 2); } else { socks5_fail(io); // no acceptable method @@ -379,7 +397,7 @@ static void socks5_handshake_start(hio_t* io) { socks5_conn_t* s5 = io->socks5; unsigned char buf[8]; int n = socks5_build_method_request(s5, buf); - if (hio_write(io, buf, n) < 0) { socks5_fail(io); return; } + if (socks5_send(io, buf, n) != 0) { socks5_fail(io); return; } socks5_expect(io, S5_RECV_METHOD, 2); hio_add(io, socks5_handshake, HV_READ); } diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index b8ca983f8..21292d550 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -73,6 +73,16 @@ cc_binary( deps = ["//:hv"] ) +cc_binary( + name = "socks5_client_test", + srcs = ["socks5_client_test.cpp"], + deps = ["//:hv"], + target_compatible_with = select({ + "//:with_evpp": [], + "//conditions:default": ["@platforms//:incompatible"], + }), +) + cc_binary( name = "jsonrpc_client", srcs = ["jsonrpc/jsonrpc_client.c", "jsonrpc/cJSON.c"] + glob(["jsonrpc/*.h"]), @@ -223,7 +233,7 @@ filegroup( ":jsonrpc_client", ":jsonrpc_server", ] + select({ - "//:with_evpp": [":hmain_test", ":nmap"], + "//:with_evpp": [":hmain_test", ":nmap", ":socks5_client_test"], "//conditions:default": [], }) + select({ "//:with_redis": [":redis_client_example", ":redis_subscriber_example"], From 72d0c9157493a31ff8fb02cadf2cc6270e8061ba Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 02:12:51 +0800 Subject: [PATCH 05/12] example(socks5): rewrite socks5_client_test in C 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 --- Makefile | 9 ++-- docs/cn/socks5.md | 2 +- examples/BUILD.bazel | 11 ++--- examples/CMakeLists.txt | 10 ++--- examples/socks5_client_test.c | 73 +++++++++++++++++++++++++++++++++ examples/socks5_client_test.cpp | 63 ---------------------------- 6 files changed, 88 insertions(+), 80 deletions(-) create mode 100644 examples/socks5_client_test.c delete mode 100644 examples/socks5_client_test.cpp diff --git a/Makefile b/Makefile index 06f06c8f3..2b7f50464 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,7 @@ EXAMPLES = hmain_test htimer_test hloop_test pipe_test \ udp_echo_server \ udp_proxy_server \ socks5_proxy_server \ + socks5_client_test \ host \ multi-acceptor-processes \ multi-acceptor-threads \ @@ -101,7 +102,7 @@ EXAMPLES = hmain_test htimer_test hloop_test pipe_test \ jsonrpc ifeq ($(WITH_EVPP), yes) -EXAMPLES += nmap socks5_client_test +EXAMPLES += nmap ifeq ($(WITH_REDIS), yes) EXAMPLES += redis_client_example redis_subscriber_example endif @@ -240,6 +241,9 @@ udp_proxy_server: prepare socks5_proxy_server: prepare $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS)" SRCS="examples/socks5_proxy_server.c" +socks5_client_test: prepare + $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS)" SRCS="examples/socks5_client_test.c" + host: prepare $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS)" SRCS="examples/host.c" @@ -270,9 +274,6 @@ tinyproxyd: prepare nmap: prepare libhv $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS) cpputil examples/nmap" DEFINES="PRINT_DEBUG" -socks5_client_test: prepare libhv - $(CXX) -g -Wall -O0 -std=c++11 -I. -Ibase -Issl -Ievent -Icpputil -Ievpp -o bin/socks5_client_test examples/socks5_client_test.cpp -Llib -lhv -pthread - ifeq ($(WITH_REDIS), yes) redis_client_example: prepare $(MAKEF) TARGET=$@ SRCDIRS="$(CORE_SRCDIRS) cpputil evpp redis" SRCS="examples/redis_client_test.cpp" diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md index 175574ccc..53bd4b896 100644 --- a/docs/cn/socks5.md +++ b/docs/cn/socks5.md @@ -75,7 +75,7 @@ int main() { } ``` -测试代码见 [examples/socks5_client_test.cpp](../../examples/socks5_client_test.cpp) +测试代码见 [examples/socks5_client_test.c](../../examples/socks5_client_test.c) 可用 libhv 自带的 SOCKS5 代理服务端做端到端测试: diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index 21292d550..6fcf69254 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -75,12 +75,8 @@ cc_binary( cc_binary( name = "socks5_client_test", - srcs = ["socks5_client_test.cpp"], - deps = ["//:hv"], - target_compatible_with = select({ - "//:with_evpp": [], - "//conditions:default": ["@platforms//:incompatible"], - }), + srcs = ["socks5_client_test.c"], + deps = ["//:hv"] ) cc_binary( @@ -230,10 +226,11 @@ filegroup( ":udp_echo_server", ":udp_proxy_server", ":socks5_proxy_server", + ":socks5_client_test", ":jsonrpc_client", ":jsonrpc_server", ] + select({ - "//:with_evpp": [":hmain_test", ":nmap", ":socks5_client_test"], + "//:with_evpp": [":hmain_test", ":nmap"], "//conditions:default": [], }) + select({ "//:with_redis": [":redis_client_example", ":redis_subscriber_example"], diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index ed72d457c..7bac59ee5 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -12,6 +12,7 @@ list(APPEND EXAMPLES udp_echo_server udp_proxy_server socks5_proxy_server + socks5_client_test host multi-acceptor-processes multi-acceptor-threads @@ -61,6 +62,9 @@ target_link_libraries(udp_proxy_server ${HV_LIBRARIES}) add_executable(socks5_proxy_server socks5_proxy_server.c) target_link_libraries(socks5_proxy_server ${HV_LIBRARIES}) +add_executable(socks5_client_test socks5_client_test.c) +target_link_libraries(socks5_client_test ${HV_LIBRARIES}) + add_executable(host host.c) target_link_libraries(host ${HV_LIBRARIES}) @@ -110,11 +114,7 @@ if(WITH_EVPP) target_compile_definitions(nmap PRIVATE PRINT_DEBUG) target_link_libraries(nmap ${HV_LIBRARIES}) - # socks5_client_test - add_executable(socks5_client_test socks5_client_test.cpp) - target_link_libraries(socks5_client_test ${HV_LIBRARIES}) - - list(APPEND EXAMPLES hmain_test nmap socks5_client_test) + list(APPEND EXAMPLES hmain_test nmap) if(WITH_REDIS) include_directories(../redis) diff --git a/examples/socks5_client_test.c b/examples/socks5_client_test.c new file mode 100644 index 000000000..38e52e803 --- /dev/null +++ b/examples/socks5_client_test.c @@ -0,0 +1,73 @@ +/* + * TCP client via SOCKS5 proxy (pure C, io layer). + * + * Demonstrates routing a connection through a SOCKS5 proxy with hio_set_socks5. + * The target host is sent to the proxy as a domain name (the proxy resolves it). + * + * @build make examples + * @test # start libhv's own SOCKS5 proxy server as the proxy: + * bin/socks5_proxy_server 1080 + * # then connect to an echo server through it: + * bin/tcp_echo_server 1234 + * bin/socks5_client_test 127.0.0.1 1080 127.0.0.1 1234 + * + * @example bin/socks5_client_test [user] [pass] + */ + +#include "hloop.h" +#include "hbase.h" + +static void on_close(hio_t* io) { + printf("disconnected: connfd=%d error=%d\n", hio_fd(io), hio_error(io)); + hloop_stop(hevent_loop(io)); +} + +static void on_message(hio_t* io, void* buf, int len) { + printf("recv: %.*s", len, (char*)buf); +} + +static void on_connect(hio_t* io) { + printf("connected through socks5 proxy, send hello\n"); + hio_setcb_read(io, on_message); + hio_read(io); + hio_write(io, "hello via socks5\n", 17); +} + +int main(int argc, char** argv) { + if (argc < 5) { + printf("Usage: %s proxy_host proxy_port target_host target_port [user] [pass]\n", argv[0]); + return -1; + } + const char* proxy_host = argv[1]; + int proxy_port = atoi(argv[2]); + const char* target_host = argv[3]; + int target_port = atoi(argv[4]); + const char* user = argc > 5 ? argv[5] : NULL; + const char* pass = argc > 6 ? argv[6] : NULL; + + hloop_t* loop = hloop_new(HLOOP_FLAG_AUTO_FREE); + // NOTE: create the socket for the real target; the proxy handshake connects + // to the proxy and issues CONNECT to this target. + hio_t* io = hio_create_socket(loop, target_host, target_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); + if (io == NULL) { + printf("create socket failed\n"); + return -1; + } + + // route through the SOCKS5 proxy + socks5_setting_t socks5; + memset(&socks5, 0, sizeof(socks5)); + hv_strncpy(socks5.host, proxy_host, sizeof(socks5.host)); + socks5.port = proxy_port; + if (user) hv_strncpy(socks5.username, user, sizeof(socks5.username)); + if (pass) hv_strncpy(socks5.password, pass, sizeof(socks5.password)); + hio_set_socks5(io, &socks5); + + hio_setcb_connect(io, on_connect); + hio_setcb_close(io, on_close); + hio_connect(io); + + hloop_run(loop); + hloop_free(&loop); + return 0; +} diff --git a/examples/socks5_client_test.cpp b/examples/socks5_client_test.cpp deleted file mode 100644 index cb25be514..000000000 --- a/examples/socks5_client_test.cpp +++ /dev/null @@ -1,63 +0,0 @@ -/* - * TCP client via SOCKS5 proxy. - * - * Demonstrates routing a TcpClient connection through a SOCKS5 proxy at the io - * layer (hio_set_socks5). The target host is sent to the proxy as a domain name - * (the proxy resolves it). - * - * @build make examples - * @test # start libhv's own SOCKS5 proxy server as the proxy: - * bin/socks5_proxy_server 1080 - * # then connect to an echo server through it: - * bin/tcp_echo_server 1234 - * bin/socks5_client_test 127.0.0.1 1080 127.0.0.1 1234 - * - * @example bin/socks5_client_test - */ - -#include "TcpClient.h" - -using namespace hv; - -int main(int argc, char** argv) { - if (argc < 5) { - printf("Usage: %s proxy_host proxy_port target_host target_port [user] [pass]\n", argv[0]); - return -1; - } - const char* proxy_host = argv[1]; - int proxy_port = atoi(argv[2]); - const char* target_host = argv[3]; - int target_port = atoi(argv[4]); - const char* user = argc > 5 ? argv[5] : NULL; - const char* pass = argc > 6 ? argv[6] : NULL; - - TcpClient cli; - int connfd = cli.createsocket(target_port, target_host); - if (connfd < 0) { - printf("createsocket failed\n"); - return -1; - } - // route through the SOCKS5 proxy - socks5_setting_t socks5; - hv_strncpy(socks5.host, proxy_host, sizeof(socks5.host)); - socks5.port = proxy_port; - if (user) hv_strncpy(socks5.username, user, sizeof(socks5.username)); - if (pass) hv_strncpy(socks5.password, pass, sizeof(socks5.password)); - cli.setSocks5Proxy(&socks5); - - cli.onConnection = [](const SocketChannelPtr& channel) { - if (channel->isConnected()) { - printf("connected through socks5 proxy, send hello\n"); - channel->write("hello via socks5\n"); - } else { - printf("disconnected\n"); - } - }; - cli.onMessage = [](const SocketChannelPtr& channel, Buffer* buf) { - printf("recv: %.*s", (int)buf->size(), (char*)buf->data()); - }; - - cli.start(); - while (1) hv_sleep(1); - return 0; -} From 66d0b2eb4d605df05a40ea5d22e1c352158f95c2 Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 02:22:55 +0800 Subject: [PATCH 06/12] example(socks5): preserve target hostname so ATYP=domain path is used 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 (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 --- examples/socks5_client_test.c | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/socks5_client_test.c b/examples/socks5_client_test.c index 38e52e803..c55027083 100644 --- a/examples/socks5_client_test.c +++ b/examples/socks5_client_test.c @@ -46,13 +46,19 @@ int main(int argc, char** argv) { const char* pass = argc > 6 ? argv[6] : NULL; hloop_t* loop = hloop_new(HLOOP_FLAG_AUTO_FREE); - // NOTE: create the socket for the real target; the proxy handshake connects - // to the proxy and issues CONNECT to this target. - hio_t* io = hio_create_socket(loop, target_host, target_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); + // Create the client socket. We do NOT resolve target_host locally: the + // proxy resolves it. Only a valid address family is needed for socket(), + // and hio_connect() recreates the fd with the proxy's family anyway, so a + // loopback placeholder is fine. The real target is carried below via + // hio_set_hostname (sent to the proxy as CONNECT :). + hio_t* io = hio_create_socket(loop, "127.0.0.1", target_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); if (io == NULL) { printf("create socket failed\n"); return -1; } + // target host sent to the proxy; a hostname => ATYP=domain (proxy resolves), + // a numeric literal => ATYP=ipv4/ipv6. + hio_set_hostname(io, target_host); // route through the SOCKS5 proxy socks5_setting_t socks5; From e093b3bf3956bf12c02562a045213d187a702718 Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 02:41:21 +0800 Subject: [PATCH 07/12] docs(socks5): clarify port placeholder + add C usage, fix ATYP wording - 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 --- docs/cn/socks5.md | 34 +++++++++++++++++++++++++++++++--- examples/socks5_client_test.c | 11 ++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md index 53bd4b896..df56365e7 100644 --- a/docs/cn/socks5.md +++ b/docs/cn/socks5.md @@ -9,7 +9,7 @@ SOCKS5 代理客户端 > 说明: > - 只做客户端代理(通过代理连出去),服务端见 [examples/socks5_proxy_server.c](../../examples/socks5_proxy_server.c)。 > - 支持无认证与用户名/密码认证(不支持 GSSAPI)。 -> - 目标地址以域名(ATYP=domain)发送给代理解析,因此配了代理时客户端本地不再做 DNS。 +> - 目标为域名时以 ATYP=domain 发给代理解析(客户端本地不做 DNS);为 IP 字面量时按 ATYP=ipv4/ipv6 发送。 > - `host` 建议直接填代理的 IP。若填域名,`hio_connect()` 会在事件循环线程内同步解析代理地址(getaddrinfo),首连及每次重连都可能短暂阻塞该 loop。 ## 配置结构 socks5_setting_t @@ -49,6 +49,36 @@ void TcpClient::setSocks5Proxy(socks5_setting_t* setting); ## 示例 +### C + +C 层没有异步 DNS,目标域名交给代理解析,所以**不要**用 `hio_create_socket(loop, target_host, ...)` 去本地解析目标(仅代理可达的域名会在这里失败)。正确做法是:用一个占位 host + **真实的目标端口**建 socket(host 只决定 socket 的地址族,`hio_connect()` 会按代理地址族重建 fd),再用 `hio_set_hostname()` 把真实目标交给握手: + +```c +#include "hloop.h" +#include "hbase.h" + +// 注意:端口用真实目标端口;host 是占位符(只定地址族),真实目标走 hio_set_hostname。 +hio_t* io = hio_create_socket(loop, "127.0.0.1", target_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); +hio_set_hostname(io, target_host); // 域名 => ATYP=domain(代理解析);IP => ATYP=ipv4/ipv6 + +socks5_setting_t socks5; +memset(&socks5, 0, sizeof(socks5)); +hv_strncpy(socks5.host, "127.0.0.1", sizeof(socks5.host)); +socks5.port = 1080; +// 如需认证: hv_strncpy(socks5.username, "user", ...); hv_strncpy(socks5.password, "pass", ...); +hio_set_socks5(io, &socks5); + +hio_setcb_connect(io, on_connect); +hio_setcb_close(io, on_close); +hio_connect(io); +``` + +完整示例见 [examples/socks5_client_test.c](../../examples/socks5_client_test.c)。 + +### C++ + +C++ 用 `TcpClient`,DNS 由其内部异步处理(配了代理时会跳过本地 DNS),`createsocket(port, host)` 直接传真实目标即可: + ```c++ #include "TcpClient.h" using namespace hv; @@ -75,8 +105,6 @@ int main() { } ``` -测试代码见 [examples/socks5_client_test.c](../../examples/socks5_client_test.c) - 可用 libhv 自带的 SOCKS5 代理服务端做端到端测试: ```sh diff --git a/examples/socks5_client_test.c b/examples/socks5_client_test.c index c55027083..22bbcec77 100644 --- a/examples/socks5_client_test.c +++ b/examples/socks5_client_test.c @@ -46,11 +46,12 @@ int main(int argc, char** argv) { const char* pass = argc > 6 ? argv[6] : NULL; hloop_t* loop = hloop_new(HLOOP_FLAG_AUTO_FREE); - // Create the client socket. We do NOT resolve target_host locally: the - // proxy resolves it. Only a valid address family is needed for socket(), - // and hio_connect() recreates the fd with the proxy's family anyway, so a - // loopback placeholder is fine. The real target is carried below via - // hio_set_hostname (sent to the proxy as CONNECT :). + // Create the client socket. hio_connect() takes the target PORT from + // peeraddr, so target_port here must be the real target port. The host, + // however, is only used to pick the socket's address family (and is then + // overridden below via hio_set_hostname / recreated with the proxy family + // in hio_connect), so a loopback placeholder is fine and target_host is NOT + // resolved locally -- the proxy resolves it. hio_t* io = hio_create_socket(loop, "127.0.0.1", target_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); if (io == NULL) { printf("create socket failed\n"); From 0e2a45e8d00bfe7b0a745c788fba0d6d1d43c529 Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 02:43:43 +0800 Subject: [PATCH 08/12] docs(socks5): correct C DNS wording 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 --- docs/cn/socks5.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md index df56365e7..bcab88a8a 100644 --- a/docs/cn/socks5.md +++ b/docs/cn/socks5.md @@ -51,7 +51,7 @@ void TcpClient::setSocks5Proxy(socks5_setting_t* setting); ### C -C 层没有异步 DNS,目标域名交给代理解析,所以**不要**用 `hio_create_socket(loop, target_host, ...)` 去本地解析目标(仅代理可达的域名会在这里失败)。正确做法是:用一个占位 host + **真实的目标端口**建 socket(host 只决定 socket 的地址族,`hio_connect()` 会按代理地址族重建 fd),再用 `hio_set_hostname()` 把真实目标交给握手: +配了代理时,目标域名应交给**代理**解析(这样只有代理可达的域名也能用,IP 字面量也才能正确走 ATYP),所以**不要**用 `hio_create_socket(loop, target_host, ...)` 在本地解析目标——该底层接口内部走同步 `getaddrinfo`(不接 `hdns` 异步解析),仅代理可达的域名会在这里直接失败。正确做法是:用一个占位 host + **真实的目标端口**建 socket(host 只决定 socket 的地址族,`hio_connect()` 会按代理地址族重建 fd),再用 `hio_set_hostname()` 把真实目标交给握手: ```c #include "hloop.h" From c273b4d1d963a967b25b41f1bbaee8344bb1e41b Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 04:21:09 +0800 Subject: [PATCH 09/12] refactor(proxy): generalize socks5 into proxy_setting_t + hio_set_proxy 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 --- docs/cn/socks5.md | 69 ++++++++++++--------- event/hevent.c | 18 +++--- event/hevent.h | 8 +-- event/hloop.h | 53 +++++++++++----- event/nio.c | 73 +++++++--------------- event/socks5.c | 17 ++--- event/socks5.h | 20 +++--- evpp/Channel.h | 6 +- evpp/TcpClient.h | 113 +++++++++++++++++++++++----------- examples/socks5_client_test.c | 36 +++++------ 10 files changed, 224 insertions(+), 189 deletions(-) diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md index bcab88a8a..7afb2e51e 100644 --- a/docs/cn/socks5.md +++ b/docs/cn/socks5.md @@ -1,8 +1,8 @@ SOCKS5 代理客户端 -在事件循环(io)层内置的 SOCKS5 客户端代理支持(RFC 1928 + RFC 1929 用户名/密码认证)。 +在事件循环(io)层内置的客户端代理支持(目前实现 SOCKS5,RFC 1928 + RFC 1929 用户名/密码认证)。 -设置后,`hio_connect()` 会先连接到 SOCKS5 代理并完成握手(向代理发起 CONNECT 到目标地址,目标以域名形式发送,由代理解析),握手成功后连接对上层透明;若开启了 SSL,则在隧道之上再与目标做 TLS 握手。 +设计上 socket 直接创建/连接到**代理**地址,`hio_connect()` 完成 TCP 连接后先跑代理握手(向代理发起 CONNECT 到目标),握手成功后连接对上层透明;若开启了 SSL,则在隧道之上再与目标做 TLS 握手。 由于挂在 `hio_connect` 上,所有基于它的客户端(`TcpClient`、`HttpClient` 等)都能直接使用。 @@ -10,27 +10,36 @@ SOCKS5 代理客户端 > - 只做客户端代理(通过代理连出去),服务端见 [examples/socks5_proxy_server.c](../../examples/socks5_proxy_server.c)。 > - 支持无认证与用户名/密码认证(不支持 GSSAPI)。 > - 目标为域名时以 ATYP=domain 发给代理解析(客户端本地不做 DNS);为 IP 字面量时按 ATYP=ipv4/ipv6 发送。 -> - `host` 建议直接填代理的 IP。若填域名,`hio_connect()` 会在事件循环线程内同步解析代理地址(getaddrinfo),首连及每次重连都可能短暂阻塞该 loop。 +> - 目前仅实现 `PROXY_PROTOCOL_SOCKS5`。 -## 配置结构 socks5_setting_t +## 配置结构 proxy_setting_t ```c -typedef struct socks5_setting_s { - char host[256]; // 代理主机 - int port; // 代理端口 - char username[256]; // 空 => 无认证 +typedef enum { + PROXY_PROTOCOL_NONE = 0, + PROXY_PROTOCOL_SOCKS5 = 1, +} proxy_protocol_e; + +typedef struct proxy_setting_s { + int protocol; // proxy_protocol_e,目前仅 SOCKS5 + char proxy_host[256]; // 代理主机(socket 连接到它) + int proxy_port; // 代理端口 + char target_host[256]; // 最终目标(代理去 CONNECT) + int target_port; + char username[256]; // 空 => 无认证 char password[256]; -} socks5_setting_t; +} proxy_setting_t; ``` -> C 用户使用前请先清零:`socks5_setting_t s5; memset(&s5, 0, sizeof(s5));`(或 `= {0}`), +> C 用户使用前请先清零:`proxy_setting_t s; memset(&s, 0, sizeof(s));`(或 `= {0}`), > 否则 username/password 为未初始化值会导致认证方式误判。C++ 有默认构造,无需手动清零。 ## C 接口 ```c -// 设置 SOCKS5 代理(setting 会被拷贝);在 hio_connect() 之前调用。 -int hio_set_socks5(hio_t* io, socks5_setting_t* setting); +// 设置代理(setting 会被拷贝);在 hio_connect() 之前调用。 +// 注意:io 必须创建到代理地址,即 hio_create_socket(loop, proxy_host, proxy_port, ...)。 +int hio_set_proxy(hio_t* io, proxy_setting_t* setting); ``` ## C++ 接口 @@ -39,10 +48,10 @@ int hio_set_socks5(hio_t* io, socks5_setting_t* setting); namespace hv { // SocketChannel -int SocketChannel::setSocks5Proxy(socks5_setting_t* setting); +int SocketChannel::setProxy(proxy_setting_t* setting); // TcpClient -void TcpClient::setSocks5Proxy(socks5_setting_t* setting); +void TcpClient::setProxy(proxy_setting_t* setting); } ``` @@ -51,22 +60,22 @@ void TcpClient::setSocks5Proxy(socks5_setting_t* setting); ### C -配了代理时,目标域名应交给**代理**解析(这样只有代理可达的域名也能用,IP 字面量也才能正确走 ATYP),所以**不要**用 `hio_create_socket(loop, target_host, ...)` 在本地解析目标——该底层接口内部走同步 `getaddrinfo`(不接 `hdns` 异步解析),仅代理可达的域名会在这里直接失败。正确做法是:用一个占位 host + **真实的目标端口**建 socket(host 只决定 socket 的地址族,`hio_connect()` 会按代理地址族重建 fd),再用 `hio_set_hostname()` 把真实目标交给握手: +C 层 socket 直接建到**代理**,目标 host/port 通过 `proxy_setting_t` 传入,由代理去 CONNECT/解析: ```c #include "hloop.h" #include "hbase.h" -// 注意:端口用真实目标端口;host 是占位符(只定地址族),真实目标走 hio_set_hostname。 -hio_t* io = hio_create_socket(loop, "127.0.0.1", target_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); -hio_set_hostname(io, target_host); // 域名 => ATYP=domain(代理解析);IP => ATYP=ipv4/ipv6 +// socket 建到代理地址 +hio_t* io = hio_create_socket(loop, proxy_host, proxy_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); -socks5_setting_t socks5; -memset(&socks5, 0, sizeof(socks5)); -hv_strncpy(socks5.host, "127.0.0.1", sizeof(socks5.host)); -socks5.port = 1080; -// 如需认证: hv_strncpy(socks5.username, "user", ...); hv_strncpy(socks5.password, "pass", ...); -hio_set_socks5(io, &socks5); +proxy_setting_t proxy; +memset(&proxy, 0, sizeof(proxy)); +proxy.protocol = PROXY_PROTOCOL_SOCKS5; +hv_strncpy(proxy.target_host, target_host, sizeof(proxy.target_host)); // 域名 => ATYP=domain(代理解析);IP => ATYP=ipv4/ipv6 +proxy.target_port = target_port; +// 如需认证: hv_strncpy(proxy.username, "user", ...); hv_strncpy(proxy.password, "pass", ...); +hio_set_proxy(io, &proxy); hio_setcb_connect(io, on_connect); hio_setcb_close(io, on_close); @@ -77,7 +86,7 @@ hio_connect(io); ### C++ -C++ 用 `TcpClient`,DNS 由其内部异步处理(配了代理时会跳过本地 DNS),`createsocket(port, host)` 直接传真实目标即可: +C++ 用 `TcpClient`:`createsocket(port, host)` 传真实目标(内部会作为 `target_*` 交给代理),代理地址填在 `proxy_setting_t.proxy_host/proxy_port`。代理若是域名,由 `TcpClient` 内部异步解析(不阻塞 loop): ```c++ #include "TcpClient.h" @@ -87,11 +96,11 @@ int main() { TcpClient cli; cli.createsocket(1234, "target.example.com"); // 目标(可为域名,由代理解析) - socks5_setting_t socks5; - hv_strncpy(socks5.host, "127.0.0.1", sizeof(socks5.host)); - socks5.port = 1080; - // 如需认证: hv_strncpy(socks5.username, "user", ...); hv_strncpy(socks5.password, "pass", ...); - cli.setSocks5Proxy(&socks5); + proxy_setting_t proxy; + hv_strncpy(proxy.proxy_host, "127.0.0.1", sizeof(proxy.proxy_host)); + proxy.proxy_port = 1080; + // 如需认证: hv_strncpy(proxy.username, "user", ...); hv_strncpy(proxy.password, "pass", ...); + cli.setProxy(&proxy); cli.onConnection = [](const SocketChannelPtr& channel) { if (channel->isConnected()) channel->write("hello via socks5"); diff --git a/event/hevent.c b/event/hevent.c index 454e1a112..cd554f285 100644 --- a/event/hevent.c +++ b/event/hevent.c @@ -136,7 +136,7 @@ void hio_ready(hio_t* io) { io->ssl_ctx = NULL; io->alloced_ssl_ctx = 0; io->hostname = NULL; - io->socks5 = NULL; + io->proxy = NULL; // context io->ctx = NULL; // private: @@ -497,14 +497,16 @@ const char* hio_get_hostname(hio_t* io) { return io->hostname; } -int hio_set_socks5(hio_t* io, socks5_setting_t* setting) { +int hio_set_proxy(hio_t* io, proxy_setting_t* setting) { if (io == NULL || setting == NULL) return -1; - if (io->socks5 == NULL) { - HV_ALLOC_SIZEOF(io->socks5); - if (io->socks5 == NULL) return -1; - } - // copy the user config; runtime fields (target/state) are filled at connect - io->socks5->setting = *setting; + // only SOCKS5 is implemented so far + if (setting->protocol != PROXY_PROTOCOL_SOCKS5) return -1; + if (io->proxy == NULL) { + HV_ALLOC_SIZEOF(io->proxy); + if (io->proxy == NULL) return -1; + } + // copy the user config; runtime fields (state/accumulator) are filled at connect + io->proxy->setting = *setting; return 0; } diff --git a/event/hevent.h b/event/hevent.h index 30d287015..bdfe0bcf2 100644 --- a/event/hevent.h +++ b/event/hevent.h @@ -186,10 +186,10 @@ struct hio_s { void* ssl; // for hio_set_ssl void* ssl_ctx; // for hio_set_ssl_ctx char* hostname; // for hssl_set_sni_hostname - // socks5 proxy (client side): if set, hio_connect dials the proxy and - // performs a SOCKS5 handshake (CONNECT to the original target) before the - // connection is handed to the upper layer / SSL handshake. - struct socks5_conn_s* socks5; + // client-side proxy: if set, hio_connect performs the proxy handshake + // (CONNECT to the target) before the connection is handed to the upper + // layer / SSL handshake. The io itself connects to the proxy address. + struct proxy_conn_s* proxy; // context void* ctx; // for hio_context / hio_set_context // private: diff --git a/event/hloop.h b/event/hloop.h index c142baaf2..66e666283 100644 --- a/event/hloop.h +++ b/event/hloop.h @@ -344,30 +344,49 @@ HV_EXPORT hssl_ctx_t hio_get_ssl_ctx(hio_t* io); HV_EXPORT int hio_set_hostname(hio_t* io, const char* hostname); HV_EXPORT const char* hio_get_hostname(hio_t* io); -// SOCKS5 proxy (client side). When set, hio_connect() dials the proxy at -// setting->host:port and performs a SOCKS5 handshake (RFC 1928), issuing a -// CONNECT to the io's original target (sent as a domain name, ATYP=domain, so -// the proxy resolves it). After the handshake succeeds the connection is -// transparent and (if SSL was enabled) the TLS handshake runs against the -// target. Because it hooks hio_connect, all clients built on it (TcpClient, -// HttpClient, ...) can use it. The setting is copied. Pass an empty username -// for no auth, or a username/password for RFC 1929 auth. +// Client-side proxy. When set, hio_connect() performs the proxy handshake +// (issuing a CONNECT to setting->target_host:target_port) before SSL / +// connect_cb; after it succeeds the connection is transparent and (if SSL was +// enabled) the TLS handshake runs against the target. Because it hooks +// hio_connect, all clients built on it (TcpClient, HttpClient, ...) can use it. +// +// IMPORTANT: the io must be created for the PROXY address, i.e. +// hio_create_socket(loop, setting.proxy_host, setting.proxy_port, ...); +// hio_set_proxy(io, &setting); +// The socket connects to the proxy; proxy_host/proxy_port are kept in the +// setting so higher layers (TcpClient) can create the socket from a single +// struct, while the io layer itself only uses target_* and the credentials. +// +// The setting is copied. Leave username empty for no auth, or set +// username/password for auth (SOCKS5 => RFC 1929). Only PROXY_PROTOCOL_SOCKS5 +// is implemented so far. // NOTE: set before hio_connect(). -typedef struct socks5_setting_s { - char host[256]; // proxy host - int port; // proxy port - char username[256]; // empty => no auth +typedef enum { + PROXY_PROTOCOL_NONE = 0, + PROXY_PROTOCOL_SOCKS5 = 1, +} proxy_protocol_e; + +typedef struct proxy_setting_s { + int protocol; // proxy_protocol_e + char proxy_host[256]; // proxy host (used to create/connect the socket) + int proxy_port; + char target_host[256]; // final target the proxy should CONNECT to + int target_port; + char username[256]; // empty => no auth char password[256]; #ifdef __cplusplus - socks5_setting_s() { - host[0] = '\0'; - port = 0; + proxy_setting_s() { + protocol = PROXY_PROTOCOL_SOCKS5; + proxy_host[0] = '\0'; + proxy_port = 0; + target_host[0] = '\0'; + target_port = 0; username[0] = '\0'; password[0] = '\0'; } #endif -} socks5_setting_t; -HV_EXPORT int hio_set_socks5(hio_t* io, socks5_setting_t* setting); +} proxy_setting_t; +HV_EXPORT int hio_set_proxy(hio_t* io, proxy_setting_t* setting); // connect timeout => hclose_cb HV_EXPORT void hio_set_connect_timeout(hio_t* io, int timeout_ms DEFAULT(HIO_DEFAULT_CONNECT_TIMEOUT)); diff --git a/event/nio.c b/event/nio.c index e005078ae..568688f56 100644 --- a/event/nio.c +++ b/event/nio.c @@ -261,7 +261,7 @@ static void socks5_fail(hio_t* io) { // advance to a new state that needs `want` more bytes, resetting the buffer. static void socks5_expect(hio_t* io, int state, int want) { - socks5_conn_t* s5 = io->socks5; + proxy_conn_t* s5 = io->proxy; s5->state = state; s5->rlen = 0; s5->want = want; @@ -287,7 +287,7 @@ static int socks5_send(hio_t* io, const void* buf, int len) { // send the SOCKS5 CONNECT request and wait for the 4-byte reply header. static void socks5_send_connect(hio_t* io) { - socks5_conn_t* s5 = io->socks5; + proxy_conn_t* s5 = io->proxy; unsigned char buf[300]; int n = socks5_build_connect_request(s5, buf); if (n < 0) { socks5_fail(io); return; } @@ -305,7 +305,7 @@ static void socks5_established(hio_t* io) { // process one accumulated step; s5->rbuf holds exactly s5->want bytes. static void socks5_dispatch(hio_t* io) { - socks5_conn_t* s5 = io->socks5; + proxy_conn_t* s5 = io->proxy; unsigned char* buf = s5->rbuf; switch (s5->state) { @@ -374,7 +374,7 @@ static void socks5_dispatch(hio_t* io) { // hio_add read handler: accumulate into s5->rbuf until s5->want bytes are // available, then dispatch. Never touches io->read_cb. static void socks5_handshake(hio_t* io) { - socks5_conn_t* s5 = io->socks5; + proxy_conn_t* s5 = io->proxy; while (s5->rlen < s5->want) { int need = s5->want - s5->rlen; if (s5->want > (int)sizeof(s5->rbuf)) { socks5_fail(io); return; } @@ -394,7 +394,7 @@ static void socks5_handshake(hio_t* io) { // Kick off the SOCKS5 handshake once the TCP connection to the proxy is up. static void socks5_handshake_start(hio_t* io) { - socks5_conn_t* s5 = io->socks5; + proxy_conn_t* s5 = io->proxy; unsigned char buf[8]; int n = socks5_build_method_request(s5, buf); if (socks5_send(io, buf, n) != 0) { socks5_fail(io); return; } @@ -402,6 +402,19 @@ static void socks5_handshake_start(hio_t* io) { hio_add(io, socks5_handshake, HV_READ); } +// Dispatch the proxy handshake by protocol (only SOCKS5 implemented so far). +static void proxy_handshake_start(hio_t* io) { + switch (io->proxy->setting.protocol) { + case PROXY_PROTOCOL_SOCKS5: + socks5_handshake_start(io); + return; + default: + io->error = ERR_INVALID_PARAM; + hio_close(io); + return; + } +} + static void nio_connect(hio_t* io) { // printd("nio_connect connfd=%d\n", io->fd); socklen_t addrlen = sizeof(sockaddr_u); @@ -414,10 +427,10 @@ static void nio_connect(hio_t* io) { addrlen = sizeof(sockaddr_u); getsockname(io->fd, io->localaddr, &addrlen); - // SOCKS5: the TCP connection is to the proxy; run the proxy handshake + // Proxy: the TCP connection is to the proxy; run the proxy handshake // (CONNECT to the real target) before SSL / connect_cb. - if (io->socks5) { - socks5_handshake_start(io); + if (io->proxy) { + proxy_handshake_start(io); return; } @@ -656,48 +669,6 @@ int hio_accept(hio_t* io) { } int hio_connect(hio_t* io) { - // SOCKS5: connect to the proxy instead of the target. The target was - // recorded on io (peeraddr/hostname) by hio_create_socket; capture it for - // the CONNECT request (sent as a domain name), then point the connection at - // the proxy. The listening socket was created with the target's address - // family, but the proxy may resolve to a different family, so recreate the - // fd with the proxy family when they differ (otherwise connect() fails with - // EAFNOSUPPORT). - if (io->socks5) { - socks5_conn_t* s5 = io->socks5; - // capture target: prefer the SNI hostname (original domain), else the - // numeric peer address; port always comes from peeraddr. - if (io->hostname && io->hostname[0]) { - hv_strncpy(s5->target_host, io->hostname, sizeof(s5->target_host)); - } else { - sockaddr_ip((sockaddr_u*)io->peeraddr, s5->target_host, sizeof(s5->target_host)); - } - s5->target_port = sockaddr_port((sockaddr_u*)io->peeraddr); - // resolve the proxy address - sockaddr_u proxyaddr; - memset(&proxyaddr, 0, sizeof(proxyaddr)); - if (sockaddr_set_ipport(&proxyaddr, s5->setting.host, s5->setting.port) != 0) { - io->error = ERR_INVALID_PARAM; - hio_close_async(io); - return -1; - } - // recreate the socket with the proxy family if it differs from the - // target family the socket was created with. - if (proxyaddr.sa.sa_family != io->peeraddr->sa_family) { - int newfd = socket(proxyaddr.sa.sa_family, SOCK_STREAM, 0); - if (newfd < 0) { - io->error = socket_errno(); - hio_close_async(io); - return -1; - } - nonblocking(newfd); - hio_detach(io); // remove from loop->ios[oldfd] - closesocket(io->fd); - io->fd = newfd; - hio_attach(io->loop, io); // re-key by newfd (handles resize) - } - hio_set_peeraddr(io, &proxyaddr.sa, sockaddr_len(&proxyaddr)); - } int ret = connect(io->fd, io->peeraddr, SOCKADDR_LEN(io->peeraddr)); #ifdef OS_WIN if (ret < 0 && socket_errno() != WSAEWOULDBLOCK) { @@ -869,7 +840,7 @@ int hio_close (hio_t* io) { io->ssl_ctx = NULL; } SAFE_FREE(io->hostname); - SAFE_FREE(io->socks5); + SAFE_FREE(io->proxy); if (io->io_type & HIO_TYPE_SOCKET) { closesocket(io->fd); } else if (io->io_type == HIO_TYPE_PIPE) { diff --git a/event/socks5.c b/event/socks5.c index ca42c59bf..c924e7b69 100644 --- a/event/socks5.c +++ b/event/socks5.c @@ -10,7 +10,7 @@ // +----+----------+----------+ // Offers NONE, plus USERPASS when auth credentials are present. // Returns the number of bytes written. -int socks5_build_method_request(const socks5_conn_t* s5, unsigned char* buf) { +int socks5_build_method_request(const proxy_conn_t* s5, unsigned char* buf) { int n = 0; buf[n++] = SOCKS5_VERSION; if (s5->setting.username[0]) { @@ -28,7 +28,7 @@ int socks5_build_method_request(const socks5_conn_t* s5, unsigned char* buf) { // +----+------+----------+------+----------+ // |VER | ULEN | UNAME | PLEN | PASSWD | // +----+------+----------+------+----------+ -int socks5_build_auth_request(const socks5_conn_t* s5, unsigned char* buf) { +int socks5_build_auth_request(const proxy_conn_t* s5, unsigned char* buf) { int n = 0; int ulen = (int)strlen(s5->setting.username); int plen = (int)strlen(s5->setting.password); @@ -47,28 +47,29 @@ int socks5_build_auth_request(const socks5_conn_t* s5, unsigned char* buf) { // An IPv4/IPv6 literal target is encoded as ATYP=1/4 (raw address bytes, per // RFC 1928); anything else is sent as ATYP=domain so the proxy resolves it. // Returns bytes written, or -1 on error (host too long). -int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf) { +int socks5_build_connect_request(const proxy_conn_t* s5, unsigned char* buf) { int n = 0; buf[n++] = SOCKS5_VERSION; buf[n++] = SOCKS5_CMD_CONNECT; buf[n++] = 0x00; // RSV + const char* target_host = s5->setting.target_host; struct in_addr addr4; struct in6_addr addr6; - if (inet_pton(AF_INET, s5->target_host, &addr4) == 1) { + if (inet_pton(AF_INET, target_host, &addr4) == 1) { buf[n++] = SOCKS5_ATYP_IPV4; memcpy(buf + n, &addr4, 4); n += 4; - } else if (inet_pton(AF_INET6, s5->target_host, &addr6) == 1) { + } else if (inet_pton(AF_INET6, target_host, &addr6) == 1) { buf[n++] = SOCKS5_ATYP_IPV6; memcpy(buf + n, &addr6, 16); n += 16; } else { - int hlen = (int)strlen(s5->target_host); + int hlen = (int)strlen(target_host); if (hlen <= 0 || hlen > 255) return -1; buf[n++] = SOCKS5_ATYP_DOMAIN; buf[n++] = (unsigned char)hlen; - memcpy(buf + n, s5->target_host, hlen); n += hlen; + memcpy(buf + n, target_host, hlen); n += hlen; } - unsigned short port = (unsigned short)s5->target_port; + unsigned short port = (unsigned short)s5->setting.target_port; buf[n++] = (unsigned char)((port >> 8) & 0xFF); buf[n++] = (unsigned char)(port & 0xFF); return n; diff --git a/event/socks5.h b/event/socks5.h index 10ec81540..9ca2a4c80 100644 --- a/event/socks5.h +++ b/event/socks5.h @@ -4,10 +4,10 @@ // Internal SOCKS5 client helpers (RFC 1928 + RFC 1929 username/password auth). // // NOTE: this is an internal header (not installed). The public API is -// socks5_setting_t + hio_set_socks5() in hloop.h. Used internally by +// proxy_setting_t + hio_set_proxy() in hloop.h. Used internally by // hio_connect() to run the proxy handshake; see nio.c. -#include "hloop.h" // socks5_setting_t +#include "hloop.h" // proxy_setting_t #define SOCKS5_VERSION 0x05 #define SOCKS5_AUTH_VERSION 0x01 // username/password auth subnegotiation @@ -28,26 +28,24 @@ // reply codes (0x00 = success) #define SOCKS5_REP_SUCCESS 0x00 -// Internal per-connection runtime state for the SOCKS5 handshake (held on +// Internal per-connection runtime state for the proxy handshake (held on // hio_t). Not part of the public configuration. -typedef struct socks5_conn_s { - socks5_setting_t setting; // copied proxy config - char target_host[256]; // address the proxy should CONNECT to - int target_port; +typedef struct proxy_conn_s { + proxy_setting_t setting; // copied proxy config (target + auth) int state; // socks5_state_e (see nio.c) // handshake read accumulator: SOCKS5 replies may be fragmented across TCP // segments, so bytes are buffered here until a full message is available. unsigned char rbuf[300]; // max reply: 4 + 1 + 255 + 2 (domain bind) int rlen; // bytes currently in rbuf int want; // bytes needed to complete the current step -} socks5_conn_t; +} proxy_conn_t; BEGIN_EXTERN_C // Build SOCKS5 handshake messages into buf; return bytes written (<0 on error). -int socks5_build_method_request (const socks5_conn_t* s5, unsigned char* buf); -int socks5_build_auth_request (const socks5_conn_t* s5, unsigned char* buf); -int socks5_build_connect_request(const socks5_conn_t* s5, unsigned char* buf); +int socks5_build_method_request (const proxy_conn_t* s5, unsigned char* buf); +int socks5_build_auth_request (const proxy_conn_t* s5, unsigned char* buf); +int socks5_build_connect_request(const proxy_conn_t* s5, unsigned char* buf); END_EXTERN_C diff --git a/evpp/Channel.h b/evpp/Channel.h index 5b11d35f8..68681b8bd 100644 --- a/evpp/Channel.h +++ b/evpp/Channel.h @@ -286,10 +286,10 @@ class SocketChannel : public Channel { return hio_set_hostname(io_, hostname.c_str()); } - // SOCKS5 proxy (client side); see hio_set_socks5. - int setSocks5Proxy(socks5_setting_t* setting) { + // Client-side proxy; see hio_set_proxy. + int setProxy(proxy_setting_t* setting) { if (io_ == NULL) return -1; - return hio_set_socks5(io_, setting); + return hio_set_proxy(io_, setting); } // timeout diff --git a/evpp/TcpClient.h b/evpp/TcpClient.h index 171b30b01..5b5e56763 100644 --- a/evpp/TcpClient.h +++ b/evpp/TcpClient.h @@ -28,7 +28,7 @@ class TcpClientEventLoopTmpl { tls_setting = NULL; reconn_setting = NULL; unpack_setting = NULL; - socks5_setting = NULL; + proxy_setting = NULL; reconn_timer_id = INVALID_TIMER_ID; dns_id = INVALID_DNS_ID; reconn_success_cnt_ = 0; @@ -41,7 +41,7 @@ class TcpClientEventLoopTmpl { HV_FREE(tls_setting); HV_FREE(reconn_setting); HV_FREE(unpack_setting); - HV_FREE(socks5_setting); + HV_FREE(proxy_setting); } const EventLoopPtr& loop() { @@ -143,13 +143,11 @@ class TcpClientEventLoopTmpl { int startConnect() { loop_->assertInLoopThread(); - // With a SOCKS5 proxy, the target host is resolved by the proxy, so skip - // client-side DNS: connect to the proxy and pass the target as a domain. - // remote_addr only needs a valid family for socket(); the proxy uses the - // hostname (set as SNI/target below), not remote_addr, to reach the target. - if (socks5_setting && remote_port >= 0 && - !remote_host.empty() && !is_ipaddr(remote_host.c_str())) { - return startConnectWithAddr(); + // Via a proxy: the socket connects to the PROXY, not the target. The + // target (createsocket's host/port) is carried in proxy_setting and + // sent to the proxy as a CONNECT. Resolve the proxy address here. + if (proxy_setting) { + return startConnectViaProxy(); } // If the target is a hostname, resolve it asynchronously through hdns // so the event loop is never blocked by getaddrinfo. This covers both @@ -166,6 +164,56 @@ class TcpClientEventLoopTmpl { return startConnectWithAddr(); } + // @internal: connect through proxy_setting. The socket is created for the + // PROXY address (so a single socket suffices regardless of address family); + // the createsocket() target is copied into the setting and delivered to the + // proxy via the SOCKS5 CONNECT. remote_addr is repurposed to hold the proxy + // address, and remote_host stays the target (used as SNI for TLS). + int startConnectViaProxy() { + // carry the target the proxy should CONNECT to + hv_strncpy(proxy_setting->target_host, remote_host.c_str(), sizeof(proxy_setting->target_host)); + proxy_setting->target_port = remote_port; + const char* proxy_host = proxy_setting->proxy_host; + int proxy_port = proxy_setting->proxy_port; + // A target-bound socket may have been created by createsocket() for a + // numeric target; drop it so the socket is (re)created for the proxy. + if (channel && channel->isClosed()) { + channel = NULL; + } + if (is_ipaddr(proxy_host)) { + memset(&remote_addr, 0, sizeof(remote_addr)); + int ret = sockaddr_set_ipport(&remote_addr, proxy_host, proxy_port); + if (ret != 0) return NABS(ret); + return startConnectWithAddr(); + } + // proxy is a hostname: resolve asynchronously (never block the loop). + cancelDnsQuery(); + hdns_setting_t opt; + opt.family = HDNS_QUERY_BOTH; + if (connect_timeout > 0) opt.timeout_ms = connect_timeout; + dns_id = loop_->resolveDns(proxy_host, + [this, proxy_port](int status, int naddrs, const sockaddr_u* addrs) { + dns_id = INVALID_DNS_ID; + if (status == HDNS_STATUS_OK && naddrs > 0) { + remote_addr = addrs[0]; + sockaddr_set_port(&remote_addr, proxy_port); + } else if (remote_addr.sa.sa_family == 0) { + hloge("resolve proxy %s failed, status=%d", proxy_setting->proxy_host, status); + onDnsResolveFailed(); + return; + } + startConnectWithAddr(); + }, &opt); + if (dns_id == INVALID_DNS_ID) { + if (remote_addr.sa.sa_family == 0) { + onDnsResolveFailed(); + return 0; + } + return startConnectWithAddr(); + } + return 0; + } + // @internal: resolve remote_host asynchronously, then connect. // Uses EventLoop::resolveDns which returns a use-after-free-proof DnsID and // manages the underlying hdns_t lifetime, so this class only holds an id. @@ -243,16 +291,8 @@ class TcpClientEventLoopTmpl { int startConnectWithAddr() { loop_->assertInLoopThread(); - // SOCKS5 + hostname target: DNS is skipped (the proxy resolves the - // target), so remote_addr has no family yet. Give it a placeholder - // family (+ the target port) so socket()/createsocket works and the io - // layer captures the target port. hio_connect() then dials the proxy - // and recreates the fd with the proxy's family if it differs, so this - // placeholder family does not matter. - if (socks5_setting && remote_addr.sa.sa_family == 0 && remote_port >= 0) { - remote_addr.sin.sin_family = AF_INET; - sockaddr_set_port(&remote_addr, remote_port); - } + // NOTE: when a proxy is set, remote_addr holds the PROXY address + // (filled by startConnectViaProxy), so the socket connects to the proxy. if (channel == NULL || channel->isClosed()) { int connfd = createsocket(&remote_addr.sa); if (connfd < 0) { @@ -266,13 +306,10 @@ class TcpClientEventLoopTmpl { if (connect_timeout) { channel->setConnectTimeout(connect_timeout); } - // SOCKS5 proxy: record the proxy + set the target host as the io - // hostname so the handshake sends CONNECT : (domain). - if (socks5_setting) { - if (!remote_host.empty() && !is_ipaddr(remote_host.c_str())) { - channel->setHostname(remote_host); - } - channel->setSocks5Proxy(socks5_setting); + // Proxy: the socket connects to the proxy; the proxy handshake issues + // CONNECT to the target carried in proxy_setting. + if (proxy_setting) { + channel->setProxy(proxy_setting); } if (tls) { channel->enableSSL(); @@ -381,19 +418,21 @@ class TcpClientEventLoopTmpl { connect_timeout = ms; } - // SOCKS5 proxy: route the connection through a SOCKS5 proxy. The target - // host is sent to the proxy as a domain name (the proxy resolves it), so - // client-side DNS is skipped when a hostname target is used with a proxy. - // The setting is copied; pass a username/password for auth (see socks5_setting_t). - void setSocks5Proxy(socks5_setting_t* setting) { + // Route the connection through a proxy (SOCKS5). The socket connects to the + // proxy (proxy_setting.proxy_host:proxy_port) and the createsocket() target + // is sent to the proxy as a CONNECT. The setting is copied; set + // username/password for auth (see proxy_setting_t). proxy_host/proxy_port + // must be filled by the caller; target_host/target_port are overwritten + // from createsocket() at connect time. + void setProxy(proxy_setting_t* setting) { if (setting == NULL) { - HV_FREE(socks5_setting); + HV_FREE(proxy_setting); return; } - if (socks5_setting == NULL) { - HV_ALLOC_SIZEOF(socks5_setting); + if (proxy_setting == NULL) { + HV_ALLOC_SIZEOF(proxy_setting); } - *socks5_setting = *setting; + *proxy_setting = *setting; } void setReconnect(reconn_setting_t* setting) { @@ -462,8 +501,8 @@ class TcpClientEventLoopTmpl { hssl_ctx_opt_t* tls_setting; reconn_setting_t* reconn_setting; unpack_setting_t* unpack_setting; - // socks5 proxy (client side), applied in startConnectWithAddr - socks5_setting_t* socks5_setting; + // client-side proxy (SOCKS5), applied in startConnectViaProxy + proxy_setting_t* proxy_setting; // Callback std::function onConnection; diff --git a/examples/socks5_client_test.c b/examples/socks5_client_test.c index 22bbcec77..b82a301dd 100644 --- a/examples/socks5_client_test.c +++ b/examples/socks5_client_test.c @@ -1,8 +1,10 @@ /* * TCP client via SOCKS5 proxy (pure C, io layer). * - * Demonstrates routing a connection through a SOCKS5 proxy with hio_set_socks5. - * The target host is sent to the proxy as a domain name (the proxy resolves it). + * Demonstrates routing a connection through a SOCKS5 proxy with hio_set_proxy. + * The socket is created for the PROXY address; the proxy issues a CONNECT to + * the target carried in proxy_setting_t (target host sent as a domain name so + * the proxy resolves it, or as ATYP=ipv4/ipv6 for a numeric literal). * * @build make examples * @test # start libhv's own SOCKS5 proxy server as the proxy: @@ -46,29 +48,23 @@ int main(int argc, char** argv) { const char* pass = argc > 6 ? argv[6] : NULL; hloop_t* loop = hloop_new(HLOOP_FLAG_AUTO_FREE); - // Create the client socket. hio_connect() takes the target PORT from - // peeraddr, so target_port here must be the real target port. The host, - // however, is only used to pick the socket's address family (and is then - // overridden below via hio_set_hostname / recreated with the proxy family - // in hio_connect), so a loopback placeholder is fine and target_host is NOT - // resolved locally -- the proxy resolves it. - hio_t* io = hio_create_socket(loop, "127.0.0.1", target_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); + // NOTE: create the socket for the PROXY (not the target). The proxy + // handshake connects to this proxy and issues CONNECT to target below. + hio_t* io = hio_create_socket(loop, proxy_host, proxy_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); if (io == NULL) { printf("create socket failed\n"); return -1; } - // target host sent to the proxy; a hostname => ATYP=domain (proxy resolves), - // a numeric literal => ATYP=ipv4/ipv6. - hio_set_hostname(io, target_host); - // route through the SOCKS5 proxy - socks5_setting_t socks5; - memset(&socks5, 0, sizeof(socks5)); - hv_strncpy(socks5.host, proxy_host, sizeof(socks5.host)); - socks5.port = proxy_port; - if (user) hv_strncpy(socks5.username, user, sizeof(socks5.username)); - if (pass) hv_strncpy(socks5.password, pass, sizeof(socks5.password)); - hio_set_socks5(io, &socks5); + // route through the SOCKS5 proxy: carry the target + optional auth + proxy_setting_t proxy; + memset(&proxy, 0, sizeof(proxy)); + proxy.protocol = PROXY_PROTOCOL_SOCKS5; + hv_strncpy(proxy.target_host, target_host, sizeof(proxy.target_host)); + proxy.target_port = target_port; + if (user) hv_strncpy(proxy.username, user, sizeof(proxy.username)); + if (pass) hv_strncpy(proxy.password, pass, sizeof(proxy.password)); + hio_set_proxy(io, &proxy); hio_setcb_connect(io, on_connect); hio_setcb_close(io, on_close); From ecd8a1d1879aa08d619dc8d59140872993d84f4c Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 04:40:35 +0800 Subject: [PATCH 10/12] refactor(proxy): TcpClient connects to proxy directly; fix TLS SNI via 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 --- docs/cn/socks5.md | 8 ++-- event/hloop.h | 15 ++++--- event/nio.c | 5 +++ evpp/TcpClient.h | 82 +++++++---------------------------- examples/socks5_client_test.c | 4 +- 5 files changed, 34 insertions(+), 80 deletions(-) diff --git a/docs/cn/socks5.md b/docs/cn/socks5.md index 7afb2e51e..e5828f9fb 100644 --- a/docs/cn/socks5.md +++ b/docs/cn/socks5.md @@ -86,7 +86,7 @@ hio_connect(io); ### C++ -C++ 用 `TcpClient`:`createsocket(port, host)` 传真实目标(内部会作为 `target_*` 交给代理),代理地址填在 `proxy_setting_t.proxy_host/proxy_port`。代理若是域名,由 `TcpClient` 内部异步解析(不阻塞 loop): +C++ 用 `TcpClient`:`createsocket(proxy_port, proxy_host)` 连接到**代理**,目标填在 `proxy_setting_t.target_host/target_port`。代理若是域名,由 `TcpClient` 内部异步解析(不阻塞 loop): ```c++ #include "TcpClient.h" @@ -94,11 +94,11 @@ using namespace hv; int main() { TcpClient cli; - cli.createsocket(1234, "target.example.com"); // 目标(可为域名,由代理解析) + cli.createsocket(1080, "127.0.0.1"); // 代理地址 proxy_setting_t proxy; - hv_strncpy(proxy.proxy_host, "127.0.0.1", sizeof(proxy.proxy_host)); - proxy.proxy_port = 1080; + hv_strncpy(proxy.target_host, "target.example.com", sizeof(proxy.target_host)); // 目标(域名由代理解析) + proxy.target_port = 1234; // 如需认证: hv_strncpy(proxy.username, "user", ...); hv_strncpy(proxy.password, "pass", ...); cli.setProxy(&proxy); diff --git a/event/hloop.h b/event/hloop.h index 66e666283..cc6aec42b 100644 --- a/event/hloop.h +++ b/event/hloop.h @@ -350,12 +350,13 @@ HV_EXPORT const char* hio_get_hostname(hio_t* io); // enabled) the TLS handshake runs against the target. Because it hooks // hio_connect, all clients built on it (TcpClient, HttpClient, ...) can use it. // -// IMPORTANT: the io must be created for the PROXY address, i.e. -// hio_create_socket(loop, setting.proxy_host, setting.proxy_port, ...); -// hio_set_proxy(io, &setting); -// The socket connects to the proxy; proxy_host/proxy_port are kept in the -// setting so higher layers (TcpClient) can create the socket from a single -// struct, while the io layer itself only uses target_* and the credentials. +// IMPORTANT: create the io for the PROXY address, then set the target here: +// hio_create_socket(loop, proxy_host, proxy_port, ...); +// hio_set_proxy(io, &setting); // setting carries the final target + auth +// The socket connects to the proxy; the io layer only uses target_* and the +// credentials. proxy_host/proxy_port are kept in the setting for reference / +// higher-level use, but the SOCKS5 path does not require them (the socket is +// already the proxy connection). // // The setting is copied. Leave username empty for no auth, or set // username/password for auth (SOCKS5 => RFC 1929). Only PROXY_PROTOCOL_SOCKS5 @@ -368,7 +369,7 @@ typedef enum { typedef struct proxy_setting_s { int protocol; // proxy_protocol_e - char proxy_host[256]; // proxy host (used to create/connect the socket) + char proxy_host[256]; // proxy host (SOCKS5: unused, socket is the proxy) int proxy_port; char target_host[256]; // final target the proxy should CONNECT to int target_port; diff --git a/event/nio.c b/event/nio.c index 568688f56..b20de7594 100644 --- a/event/nio.c +++ b/event/nio.c @@ -227,6 +227,11 @@ static void nio_connect_established(hio_t* io) { } if (io->hostname) { hssl_set_sni_hostname(io->ssl, io->hostname); + } else if (io->proxy && io->proxy->setting.target_host[0]) { + // through a proxy the TLS peer is the target, and the socket was + // created for the proxy (so io->hostname is unset); use the target + // as SNI unless the caller set an explicit hostname above. + hssl_set_sni_hostname(io->ssl, io->proxy->setting.target_host); } ssl_client_handshake(io); } diff --git a/evpp/TcpClient.h b/evpp/TcpClient.h index 5b5e56763..c4eafd802 100644 --- a/evpp/TcpClient.h +++ b/evpp/TcpClient.h @@ -143,12 +143,6 @@ class TcpClientEventLoopTmpl { int startConnect() { loop_->assertInLoopThread(); - // Via a proxy: the socket connects to the PROXY, not the target. The - // target (createsocket's host/port) is carried in proxy_setting and - // sent to the proxy as a CONNECT. Resolve the proxy address here. - if (proxy_setting) { - return startConnectViaProxy(); - } // If the target is a hostname, resolve it asynchronously through hdns // so the event loop is never blocked by getaddrinfo. This covers both // the first connect and every reconnect (to pick up DNS changes). @@ -158,62 +152,15 @@ class TcpClientEventLoopTmpl { // NOTE: Unix Domain Socket targets (remote_port < 0) carry a filesystem // path in remote_host, not a hostname; remote_addr is already set by // createsocket(), so never run DNS on them. + // NOTE: with a proxy, remote_host/remote_port ARE the proxy (that is + // what createsocket connects to); the final target lives in + // proxy_setting. So this same DNS path resolves the proxy address. if (remote_port >= 0 && !remote_host.empty() && !is_ipaddr(remote_host.c_str())) { return startResolveThenConnect(); } return startConnectWithAddr(); } - // @internal: connect through proxy_setting. The socket is created for the - // PROXY address (so a single socket suffices regardless of address family); - // the createsocket() target is copied into the setting and delivered to the - // proxy via the SOCKS5 CONNECT. remote_addr is repurposed to hold the proxy - // address, and remote_host stays the target (used as SNI for TLS). - int startConnectViaProxy() { - // carry the target the proxy should CONNECT to - hv_strncpy(proxy_setting->target_host, remote_host.c_str(), sizeof(proxy_setting->target_host)); - proxy_setting->target_port = remote_port; - const char* proxy_host = proxy_setting->proxy_host; - int proxy_port = proxy_setting->proxy_port; - // A target-bound socket may have been created by createsocket() for a - // numeric target; drop it so the socket is (re)created for the proxy. - if (channel && channel->isClosed()) { - channel = NULL; - } - if (is_ipaddr(proxy_host)) { - memset(&remote_addr, 0, sizeof(remote_addr)); - int ret = sockaddr_set_ipport(&remote_addr, proxy_host, proxy_port); - if (ret != 0) return NABS(ret); - return startConnectWithAddr(); - } - // proxy is a hostname: resolve asynchronously (never block the loop). - cancelDnsQuery(); - hdns_setting_t opt; - opt.family = HDNS_QUERY_BOTH; - if (connect_timeout > 0) opt.timeout_ms = connect_timeout; - dns_id = loop_->resolveDns(proxy_host, - [this, proxy_port](int status, int naddrs, const sockaddr_u* addrs) { - dns_id = INVALID_DNS_ID; - if (status == HDNS_STATUS_OK && naddrs > 0) { - remote_addr = addrs[0]; - sockaddr_set_port(&remote_addr, proxy_port); - } else if (remote_addr.sa.sa_family == 0) { - hloge("resolve proxy %s failed, status=%d", proxy_setting->proxy_host, status); - onDnsResolveFailed(); - return; - } - startConnectWithAddr(); - }, &opt); - if (dns_id == INVALID_DNS_ID) { - if (remote_addr.sa.sa_family == 0) { - onDnsResolveFailed(); - return 0; - } - return startConnectWithAddr(); - } - return 0; - } - // @internal: resolve remote_host asynchronously, then connect. // Uses EventLoop::resolveDns which returns a use-after-free-proof DnsID and // manages the underlying hdns_t lifetime, so this class only holds an id. @@ -291,8 +238,8 @@ class TcpClientEventLoopTmpl { int startConnectWithAddr() { loop_->assertInLoopThread(); - // NOTE: when a proxy is set, remote_addr holds the PROXY address - // (filled by startConnectViaProxy), so the socket connects to the proxy. + // NOTE: with a proxy, remote_addr/remote_host is the PROXY (that is what + // we connect to); the final target lives in proxy_setting. if (channel == NULL || channel->isClosed()) { int connfd = createsocket(&remote_addr.sa); if (connfd < 0) { @@ -321,8 +268,11 @@ class TcpClientEventLoopTmpl { return ret; } } - if (!is_ipaddr(remote_host.c_str())) { - channel->setHostname(remote_host); + // SNI = the TLS peer. Through a proxy the TLS peer is the target + // (proxy_setting->target_host), otherwise it is remote_host. + const char* sni = proxy_setting ? proxy_setting->target_host : remote_host.c_str(); + if (sni && sni[0] && !is_ipaddr(sni)) { + channel->setHostname(sni); } } channel->onconnect = [this]() { @@ -418,12 +368,10 @@ class TcpClientEventLoopTmpl { connect_timeout = ms; } - // Route the connection through a proxy (SOCKS5). The socket connects to the - // proxy (proxy_setting.proxy_host:proxy_port) and the createsocket() target - // is sent to the proxy as a CONNECT. The setting is copied; set - // username/password for auth (see proxy_setting_t). proxy_host/proxy_port - // must be filled by the caller; target_host/target_port are overwritten - // from createsocket() at connect time. + // Route the connection through a proxy (SOCKS5). Create the client socket + // for the PROXY (createsocket(proxy_port, proxy_host)); this setting carries + // the final target the proxy should CONNECT to. The setting is copied; set + // username/password for auth (see proxy_setting_t). void setProxy(proxy_setting_t* setting) { if (setting == NULL) { HV_FREE(proxy_setting); @@ -501,7 +449,7 @@ class TcpClientEventLoopTmpl { hssl_ctx_opt_t* tls_setting; reconn_setting_t* reconn_setting; unpack_setting_t* unpack_setting; - // client-side proxy (SOCKS5), applied in startConnectViaProxy + // client-side proxy (SOCKS5), applied in startConnectWithAddr proxy_setting_t* proxy_setting; // Callback diff --git a/examples/socks5_client_test.c b/examples/socks5_client_test.c index b82a301dd..3ad3875ec 100644 --- a/examples/socks5_client_test.c +++ b/examples/socks5_client_test.c @@ -48,8 +48,8 @@ int main(int argc, char** argv) { const char* pass = argc > 6 ? argv[6] : NULL; hloop_t* loop = hloop_new(HLOOP_FLAG_AUTO_FREE); - // NOTE: create the socket for the PROXY (not the target). The proxy - // handshake connects to this proxy and issues CONNECT to target below. + // create the socket for the PROXY; the proxy handshake then CONNECTs to the + // target carried in proxy_setting below. hio_t* io = hio_create_socket(loop, proxy_host, proxy_port, HIO_TYPE_TCP, HIO_CLIENT_SIDE); if (io == NULL) { printf("create socket failed\n"); From 8715fb23ec2dc1dde833f4625ffc7bf634410fdf Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 11:20:16 +0800 Subject: [PATCH 11/12] fix(proxy): prefer proxy target_host for TLS SNI over io->hostname 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 --- event/nio.c | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/event/nio.c b/event/nio.c index b20de7594..f4f6f0397 100644 --- a/event/nio.c +++ b/event/nio.c @@ -225,13 +225,17 @@ static void nio_connect_established(hio_t* io) { } io->ssl = ssl; } - if (io->hostname) { - hssl_set_sni_hostname(io->ssl, io->hostname); - } else if (io->proxy && io->proxy->setting.target_host[0]) { - // through a proxy the TLS peer is the target, and the socket was - // created for the proxy (so io->hostname is unset); use the target - // as SNI unless the caller set an explicit hostname above. - hssl_set_sni_hostname(io->ssl, io->proxy->setting.target_host); + // SNI: through a proxy the TLS peer is the target, so the proxy's + // target_host is authoritative; otherwise use the explicitly-set + // io->hostname. + const char* sni = NULL; + if (io->proxy && io->proxy->setting.target_host[0]) { + sni = io->proxy->setting.target_host; + } else if (io->hostname) { + sni = io->hostname; + } + if (sni) { + hssl_set_sni_hostname(io->ssl, sni); } ssl_client_handshake(io); } From 624ee42760d18c9c242d2bd41394a8509ba3558e Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 19:38:40 +0800 Subject: [PATCH 12/12] fix(proxy): skip IP-literal SNI per candidate (target_host, then hostname) 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 --- event/nio.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/event/nio.c b/event/nio.c index f4f6f0397..202e14519 100644 --- a/event/nio.c +++ b/event/nio.c @@ -227,11 +227,12 @@ static void nio_connect_established(hio_t* io) { } // SNI: through a proxy the TLS peer is the target, so the proxy's // target_host is authoritative; otherwise use the explicitly-set - // io->hostname. + // io->hostname. SNI must be a hostname, not an IP literal (RFC 6066), + // so a numeric candidate is skipped and the next one is considered. const char* sni = NULL; - if (io->proxy && io->proxy->setting.target_host[0]) { + if (io->proxy && io->proxy->setting.target_host[0] && !is_ipaddr(io->proxy->setting.target_host)) { sni = io->proxy->setting.target_host; - } else if (io->hostname) { + } else if (io->hostname && !is_ipaddr(io->hostname)) { sni = io->hostname; } if (sni) {