mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Add SOCKS proxy support for Bun HTTP client
Implements SOCKS5 and SOCKS5h proxy support as requested in issue #16812. This adds native SOCKS proxy functionality to Bun's HTTP client: - Support for socks5:// and socks5h:// protocols - Environment variable support (http_proxy, https_proxy) - Direct proxy option support in fetch() - Full SOCKS5 handshake implementation - Integration with existing HTTP proxy infrastructure Key changes: - Add SOCKSProxy.zig implementing SOCKS5 protocol - Update HTTPThread.zig to recognize SOCKS protocols - Modify HTTP client to handle SOCKS proxy tunneling - Add URL helpers for SOCKS protocol detection - Include comprehensive test coverage Resolves issue #16812 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -546,6 +546,7 @@ src/http/MimeType.zig
|
||||
src/http/ProxyTunnel.zig
|
||||
src/http/SendFile.zig
|
||||
src/http/Signals.zig
|
||||
src/http/SOCKSProxy.zig
|
||||
src/http/ThreadSafeStreamBuffer.zig
|
||||
src/http/URLPath.zig
|
||||
src/http/websocket_client.zig
|
||||
|
||||
84
src/http.zig
84
src/http.zig
@@ -200,6 +200,11 @@ pub fn onClose(
|
||||
tunnel.shutdown();
|
||||
tunnel.detachAndDeref();
|
||||
}
|
||||
if (client.socks_proxy) |socks| {
|
||||
client.socks_proxy = null;
|
||||
socks.shutdown();
|
||||
socks.detachAndDeref();
|
||||
}
|
||||
const in_progress = client.state.stage != .done and client.state.stage != .fail and client.state.flags.is_redirect_pending == false;
|
||||
if (client.state.flags.is_redirect_pending) {
|
||||
// if the connection is closed and we are pending redirect just do the redirect
|
||||
@@ -433,6 +438,7 @@ request_content_len_buf: ["-4294967295".len]u8 = undefined,
|
||||
http_proxy: ?URL = null,
|
||||
proxy_authorization: ?[]u8 = null,
|
||||
proxy_tunnel: ?*ProxyTunnel = null,
|
||||
socks_proxy: ?*SOCKSProxy = null,
|
||||
signals: Signals = .{},
|
||||
async_http_id: u32 = 0,
|
||||
hostname: ?[]u8 = null,
|
||||
@@ -451,6 +457,10 @@ pub fn deinit(this: *HTTPClient) void {
|
||||
this.proxy_tunnel = null;
|
||||
tunnel.detachAndDeref();
|
||||
}
|
||||
if (this.socks_proxy) |socks| {
|
||||
this.socks_proxy = null;
|
||||
socks.detachAndDeref();
|
||||
}
|
||||
this.unix_socket_path.deinit();
|
||||
this.unix_socket_path = JSC.ZigString.Slice.empty;
|
||||
}
|
||||
@@ -460,7 +470,7 @@ pub fn isKeepAlivePossible(this: *HTTPClient) bool {
|
||||
// TODO keepalive for unix sockets
|
||||
if (this.unix_socket_path.length() > 0) return false;
|
||||
// is not possible to reuse Proxy with TSL, so disable keepalive if url is tunneling HTTPS
|
||||
if (this.proxy_tunnel != null or (this.http_proxy != null and this.url.isHTTPS())) {
|
||||
if (this.proxy_tunnel != null or this.socks_proxy != null or (this.http_proxy != null and this.url.isHTTPS())) {
|
||||
log("Keep-Alive release (proxy tunneling https)", .{});
|
||||
return false;
|
||||
}
|
||||
@@ -708,6 +718,14 @@ pub fn doRedirect(
|
||||
log("close socket in redirect", .{});
|
||||
NewHTTPContext(is_ssl).closeSocket(socket);
|
||||
}
|
||||
} else if (this.socks_proxy) |socks| {
|
||||
log("close the socks proxy in redirect", .{});
|
||||
this.socks_proxy = null;
|
||||
socks.detachAndDeref();
|
||||
if (!socket.isClosed()) {
|
||||
log("close socket in redirect", .{});
|
||||
NewHTTPContext(is_ssl).closeSocket(socket);
|
||||
}
|
||||
} else {
|
||||
// we need to clean the client reference before closing the socket because we are going to reuse the same ref in a another request
|
||||
if (this.isKeepAlivePossible()) {
|
||||
@@ -739,6 +757,10 @@ pub fn doRedirect(
|
||||
this.proxy_tunnel = null;
|
||||
tunnel.detachAndDeref();
|
||||
}
|
||||
if (this.socks_proxy) |socks| {
|
||||
this.socks_proxy = null;
|
||||
socks.detachAndDeref();
|
||||
}
|
||||
|
||||
return this.start(.{ .bytes = request_body }, body_out_str);
|
||||
}
|
||||
@@ -883,8 +905,13 @@ noinline fn sendInitialRequestPayload(this: *HTTPClient, comptime is_first_call:
|
||||
|
||||
const request = this.buildRequest(this.state.original_request_body.len());
|
||||
|
||||
if (this.http_proxy) |_| {
|
||||
if (this.url.isHTTPS()) {
|
||||
if (this.http_proxy) |proxy| {
|
||||
if (proxy.isSOCKS()) {
|
||||
log("start SOCKS proxy tunneling", .{});
|
||||
// SOCKS proxy - always requires tunneling regardless of target protocol
|
||||
this.flags.proxy_tunneling = true;
|
||||
// Don't write any HTTP headers yet - SOCKS handshake happens first
|
||||
} else if (this.url.isHTTPS()) {
|
||||
log("start proxy tunneling (https proxy)", .{});
|
||||
//DO the tunneling!
|
||||
this.flags.proxy_tunneling = true;
|
||||
@@ -1283,9 +1310,23 @@ pub fn closeAndFail(this: *HTTPClient, err: anyerror, comptime is_ssl: bool, soc
|
||||
|
||||
fn startProxyHandshake(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, start_payload: []const u8) void {
|
||||
log("startProxyHandshake", .{});
|
||||
// if we have options we pass them (ca, reject_unauthorized, etc) otherwise use the default
|
||||
const ssl_options = if (this.tls_props != null) this.tls_props.?.* else JSC.API.ServerConfig.SSLConfig.zero;
|
||||
ProxyTunnel.start(this, is_ssl, socket, ssl_options, start_payload);
|
||||
|
||||
if (this.http_proxy) |proxy| {
|
||||
if (proxy.isSOCKS()) {
|
||||
// Start SOCKS proxy handshake
|
||||
this.socks_proxy = SOCKSProxy.create(this.allocator, proxy, this.url.hostname, this.url.getPortAuto()) catch |err| {
|
||||
this.closeAndFail(err, is_ssl, socket);
|
||||
return;
|
||||
};
|
||||
if (this.socks_proxy) |socks| {
|
||||
socks.sendAuthHandshake(is_ssl, socket);
|
||||
}
|
||||
} else {
|
||||
// HTTP proxy - use existing ProxyTunnel
|
||||
const ssl_options = if (this.tls_props != null) this.tls_props.?.* else JSC.API.ServerConfig.SSLConfig.zero;
|
||||
ProxyTunnel.start(this, is_ssl, socket, ssl_options, start_payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fn handleShortRead(
|
||||
@@ -1399,12 +1440,24 @@ pub fn handleOnDataHeaders(
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.flags.proxy_tunneling and this.proxy_tunnel == null) {
|
||||
if (this.flags.proxy_tunneling and this.proxy_tunnel == null and this.socks_proxy == null) {
|
||||
// we are proxing we dont need to cloneMetadata yet
|
||||
this.startProxyHandshake(is_ssl, socket, body_buf);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle SOCKS proxy data
|
||||
if (this.socks_proxy) |socks| {
|
||||
const consumed = socks.handleData(this, body_buf, is_ssl, socket) catch |err| {
|
||||
this.closeAndFail(err, is_ssl, socket);
|
||||
return;
|
||||
};
|
||||
if (consumed) {
|
||||
return; // SOCKS handshake consumed the data
|
||||
}
|
||||
// Fall through to normal HTTP processing if SOCKS proxy is connected
|
||||
}
|
||||
|
||||
// we have body data incoming so we clone metadata and keep going
|
||||
this.cloneMetadata();
|
||||
|
||||
@@ -1537,6 +1590,11 @@ fn fail(this: *HTTPClient, err: anyerror) void {
|
||||
// always detach the socket from the tunnel in case of fail
|
||||
tunnel.detachAndDeref();
|
||||
}
|
||||
if (this.socks_proxy) |socks| {
|
||||
this.socks_proxy = null;
|
||||
socks.shutdown();
|
||||
socks.detachAndDeref();
|
||||
}
|
||||
if (this.state.stage != .done and this.state.stage != .fail) {
|
||||
this.state.request_stage = .fail;
|
||||
this.state.response_stage = .fail;
|
||||
@@ -1626,6 +1684,15 @@ pub fn progressUpdate(this: *HTTPClient, comptime is_ssl: bool, ctx: *NewHTTPCon
|
||||
log("close socket", .{});
|
||||
NewHTTPContext(is_ssl).closeSocket(socket);
|
||||
}
|
||||
} else if (this.socks_proxy) |socks| {
|
||||
log("close the socks proxy", .{});
|
||||
this.socks_proxy = null;
|
||||
socks.shutdown();
|
||||
socks.detachAndDeref();
|
||||
if (!socket.isClosed()) {
|
||||
log("close socket", .{});
|
||||
NewHTTPContext(is_ssl).closeSocket(socket);
|
||||
}
|
||||
} else {
|
||||
if (this.isKeepAlivePossible() and !socket.isClosedOrHasError()) {
|
||||
log("release socket", .{});
|
||||
@@ -2159,7 +2226,7 @@ pub fn handleResponseMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
if (this.flags.proxy_tunneling and this.proxy_tunnel == null) {
|
||||
if (this.flags.proxy_tunneling and this.proxy_tunnel == null and this.socks_proxy == null) {
|
||||
if (response.status_code == 200) {
|
||||
// signal to continue the proxing
|
||||
return ShouldContinue.continue_streaming;
|
||||
@@ -2452,6 +2519,7 @@ const SSLConfig = @import("./bun.js/api/server.zig").ServerConfig.SSLConfig;
|
||||
const uws = bun.uws;
|
||||
const HTTPCertError = @import("./http/HTTPCertError.zig");
|
||||
const ProxyTunnel = @import("./http/ProxyTunnel.zig");
|
||||
const SOCKSProxy = @import("./http/SOCKSProxy.zig");
|
||||
pub const Headers = @import("./http/Headers.zig");
|
||||
pub const MimeType = @import("./http/MimeType.zig");
|
||||
pub const URLPath = @import("./http/URLPath.zig");
|
||||
|
||||
@@ -263,6 +263,9 @@ pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !NewH
|
||||
if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) {
|
||||
return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto());
|
||||
}
|
||||
if (url.isSOCKS()) {
|
||||
return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto());
|
||||
}
|
||||
return error.UnsupportedProxyProtocol;
|
||||
}
|
||||
return try custom_context.connect(client, client.url.hostname, client.url.getPortAuto());
|
||||
@@ -274,6 +277,9 @@ pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !NewH
|
||||
if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) {
|
||||
return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto());
|
||||
}
|
||||
if (url.isSOCKS()) {
|
||||
return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto());
|
||||
}
|
||||
return error.UnsupportedProxyProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
240
src/http/SOCKSProxy.zig
Normal file
240
src/http/SOCKSProxy.zig
Normal file
@@ -0,0 +1,240 @@
|
||||
const SOCKSProxy = @This();
|
||||
const RefCount = bun.ptr.RefCount(@This(), "ref_count", SOCKSProxy.deinit, .{});
|
||||
pub const ref = SOCKSProxy.RefCount.ref;
|
||||
pub const deref = SOCKSProxy.RefCount.deref;
|
||||
|
||||
state: SOCKSState = .init,
|
||||
destination_host: []const u8 = "",
|
||||
destination_port: u16 = 0,
|
||||
proxy_url: URL,
|
||||
allocator: std.mem.Allocator,
|
||||
ref_count: RefCount,
|
||||
|
||||
const SOCKSState = enum {
|
||||
init,
|
||||
auth_handshake,
|
||||
auth_complete,
|
||||
connect_request,
|
||||
connected,
|
||||
failed,
|
||||
};
|
||||
|
||||
const SOCKSVersion = enum(u8) {
|
||||
v5 = 0x05,
|
||||
};
|
||||
|
||||
const SOCKSAuthMethod = enum(u8) {
|
||||
no_auth = 0x00,
|
||||
gssapi = 0x01,
|
||||
username_password = 0x02,
|
||||
no_acceptable = 0xFF,
|
||||
};
|
||||
|
||||
const SOCKSCommand = enum(u8) {
|
||||
connect = 0x01,
|
||||
bind = 0x02,
|
||||
udp_associate = 0x03,
|
||||
};
|
||||
|
||||
const SOCKSAddressType = enum(u8) {
|
||||
ipv4 = 0x01,
|
||||
domain_name = 0x03,
|
||||
ipv6 = 0x04,
|
||||
};
|
||||
|
||||
const SOCKSReply = enum(u8) {
|
||||
succeeded = 0x00,
|
||||
general_failure = 0x01,
|
||||
connection_not_allowed = 0x02,
|
||||
network_unreachable = 0x03,
|
||||
host_unreachable = 0x04,
|
||||
connection_refused = 0x05,
|
||||
ttl_expired = 0x06,
|
||||
command_not_supported = 0x07,
|
||||
address_type_not_supported = 0x08,
|
||||
};
|
||||
|
||||
pub fn create(allocator: std.mem.Allocator, proxy_url: URL, destination_host: []const u8, destination_port: u16) !*SOCKSProxy {
|
||||
const socks_proxy = bun.new(SOCKSProxy, .{
|
||||
.ref_count = .init(),
|
||||
.proxy_url = proxy_url,
|
||||
.destination_host = destination_host,
|
||||
.destination_port = destination_port,
|
||||
.allocator = allocator,
|
||||
});
|
||||
|
||||
return socks_proxy;
|
||||
}
|
||||
|
||||
pub fn sendAuthHandshake(this: *SOCKSProxy, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) void {
|
||||
// SOCKS5 authentication handshake
|
||||
// +----+----------+----------+
|
||||
// |VER | NMETHODS | METHODS |
|
||||
// +----+----------+----------+
|
||||
// | 1 | 1 | 1 to 255 |
|
||||
// +----+----------+----------+
|
||||
var auth_request = [_]u8{ @intFromEnum(SOCKSVersion.v5), 1, @intFromEnum(SOCKSAuthMethod.no_auth) };
|
||||
|
||||
_ = socket.write(&auth_request);
|
||||
this.state = .auth_handshake;
|
||||
}
|
||||
|
||||
pub fn sendConnectRequest(this: *SOCKSProxy, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) !void {
|
||||
// SOCKS5 connect request
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
|
||||
var buffer = std.ArrayList(u8).init(this.allocator);
|
||||
defer buffer.deinit();
|
||||
|
||||
// Version, Command, Reserved
|
||||
try buffer.appendSlice(&[_]u8{ @intFromEnum(SOCKSVersion.v5), @intFromEnum(SOCKSCommand.connect), 0x00 });
|
||||
|
||||
// Address type and address
|
||||
if (strings.isIPAddress(this.destination_host)) {
|
||||
if (strings.indexOf(this.destination_host, ":")) |_| {
|
||||
// IPv6
|
||||
try buffer.append(@intFromEnum(SOCKSAddressType.ipv6));
|
||||
const parsed = std.net.Ip6Address.parse(this.destination_host, 0) catch {
|
||||
return error.InvalidIPv6Address;
|
||||
};
|
||||
try buffer.appendSlice(std.mem.asBytes(&parsed.sa.addr));
|
||||
} else {
|
||||
// IPv4
|
||||
try buffer.append(@intFromEnum(SOCKSAddressType.ipv4));
|
||||
const parsed = std.net.Ip4Address.parse(this.destination_host, 0) catch {
|
||||
return error.InvalidIPv4Address;
|
||||
};
|
||||
try buffer.appendSlice(std.mem.asBytes(&parsed.sa.addr));
|
||||
}
|
||||
} else {
|
||||
// Domain name
|
||||
try buffer.append(@intFromEnum(SOCKSAddressType.domain_name));
|
||||
if (this.destination_host.len > 255) {
|
||||
return error.DomainNameTooLong;
|
||||
}
|
||||
try buffer.append(@intCast(this.destination_host.len));
|
||||
try buffer.appendSlice(this.destination_host);
|
||||
}
|
||||
|
||||
// Port (big-endian)
|
||||
const port_bytes = std.mem.toBytes(std.mem.nativeToBig(u16, this.destination_port));
|
||||
try buffer.appendSlice(&port_bytes);
|
||||
|
||||
// Send the request
|
||||
_ = socket.write(buffer.items);
|
||||
this.state = .connect_request;
|
||||
}
|
||||
|
||||
pub fn handleData(this: *SOCKSProxy, client: *HTTPClient, data: []const u8, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) !bool {
|
||||
_ = client;
|
||||
switch (this.state) {
|
||||
.auth_handshake => {
|
||||
if (data.len < 2) {
|
||||
return error.IncompleteSOCKSResponse;
|
||||
}
|
||||
|
||||
const version = data[0];
|
||||
const method = data[1];
|
||||
|
||||
if (version != @intFromEnum(SOCKSVersion.v5)) {
|
||||
return error.UnsupportedSOCKSVersion;
|
||||
}
|
||||
|
||||
if (method == @intFromEnum(SOCKSAuthMethod.no_acceptable)) {
|
||||
return error.SOCKSAuthenticationFailed;
|
||||
}
|
||||
|
||||
if (method == @intFromEnum(SOCKSAuthMethod.no_auth)) {
|
||||
this.state = .auth_complete;
|
||||
try this.sendConnectRequest(is_ssl, socket);
|
||||
} else {
|
||||
return error.UnsupportedSOCKSAuthMethod;
|
||||
}
|
||||
|
||||
return true; // Data was consumed by SOCKS handshake
|
||||
},
|
||||
.connect_request => {
|
||||
if (data.len < 4) {
|
||||
return error.IncompleteSOCKSResponse;
|
||||
}
|
||||
|
||||
const version = data[0];
|
||||
const reply = data[1];
|
||||
// data[2] is reserved
|
||||
const atyp = data[3];
|
||||
|
||||
if (version != @intFromEnum(SOCKSVersion.v5)) {
|
||||
return error.UnsupportedSOCKSVersion;
|
||||
}
|
||||
|
||||
if (reply != @intFromEnum(SOCKSReply.succeeded)) {
|
||||
return error.SOCKSConnectionFailed;
|
||||
}
|
||||
|
||||
// Parse the bound address (we don't need it, but need to skip it)
|
||||
var offset: usize = 4;
|
||||
switch (atyp) {
|
||||
@intFromEnum(SOCKSAddressType.ipv4) => offset += 4,
|
||||
@intFromEnum(SOCKSAddressType.ipv6) => offset += 16,
|
||||
@intFromEnum(SOCKSAddressType.domain_name) => {
|
||||
if (data.len <= offset) return error.IncompleteSOCKSResponse;
|
||||
offset += 1 + data[offset]; // domain length + domain
|
||||
},
|
||||
else => return error.UnsupportedSOCKSAddressType,
|
||||
}
|
||||
offset += 2; // port
|
||||
|
||||
if (data.len < offset) {
|
||||
return error.IncompleteSOCKSResponse;
|
||||
}
|
||||
|
||||
this.state = .connected;
|
||||
log("SOCKS proxy connected successfully", .{});
|
||||
|
||||
// SOCKS handshake complete, HTTP traffic can now flow through the tunnel
|
||||
// Don't change proxy_tunneling flag - let the normal flow handle it
|
||||
|
||||
// If there's any remaining data after the SOCKS response, process it as HTTP
|
||||
if (data.len > offset) {
|
||||
return false; // Let HTTP client process remaining data
|
||||
}
|
||||
|
||||
return true; // Data was consumed by SOCKS handshake
|
||||
},
|
||||
.connected => {
|
||||
// Pass through data to the HTTP client
|
||||
return false; // Let HTTP client handle this data
|
||||
},
|
||||
else => {
|
||||
return error.UnexpectedSOCKSState;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(this: *SOCKSProxy) void {
|
||||
this.state = .failed;
|
||||
}
|
||||
|
||||
pub fn shutdown(this: *SOCKSProxy) void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
pub fn detachAndDeref(this: *SOCKSProxy) void {
|
||||
this.deref();
|
||||
}
|
||||
|
||||
fn deinit(this: *SOCKSProxy) void {
|
||||
bun.destroy(this);
|
||||
}
|
||||
|
||||
const bun = @import("bun");
|
||||
const std = @import("std");
|
||||
const strings = bun.strings;
|
||||
const NewHTTPContext = bun.http.NewHTTPContext;
|
||||
const HTTPClient = bun.http;
|
||||
const URL = bun.URL;
|
||||
const log = bun.Output.scoped(.http_socks_proxy, false);
|
||||
12
src/url.zig
12
src/url.zig
@@ -112,6 +112,18 @@ pub const URL = struct {
|
||||
return strings.eqlComptime(this.protocol, "http");
|
||||
}
|
||||
|
||||
pub inline fn isSOCKS5(this: *const URL) bool {
|
||||
return strings.eqlComptime(this.protocol, "socks5");
|
||||
}
|
||||
|
||||
pub inline fn isSOCKS5h(this: *const URL) bool {
|
||||
return strings.eqlComptime(this.protocol, "socks5h");
|
||||
}
|
||||
|
||||
pub inline fn isSOCKS(this: *const URL) bool {
|
||||
return this.isSOCKS5() or this.isSOCKS5h();
|
||||
}
|
||||
|
||||
pub fn displayHostname(this: *const URL) string {
|
||||
if (this.hostname.len > 0) {
|
||||
return this.hostname;
|
||||
|
||||
118
test/js/bun/http/socks-proxy-env.test.ts
Normal file
118
test/js/bun/http/socks-proxy-env.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { spawn, type ChildProcess } from "bun";
|
||||
|
||||
describe("SOCKS proxy environment variables", () => {
|
||||
let mockSocksServer: ChildProcess;
|
||||
let socksPort: number;
|
||||
let httpPort: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Find available ports
|
||||
socksPort = 9050 + Math.floor(Math.random() * 1000);
|
||||
httpPort = 8080 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Start a mock SOCKS5 server for testing
|
||||
mockSocksServer = spawn({
|
||||
cmd: ["node", "-e", `
|
||||
const net = require('net');
|
||||
const server = net.createServer((socket) => {
|
||||
console.log('SOCKS connection received');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
console.log('SOCKS data:', data.toString('hex'));
|
||||
|
||||
// Handle SOCKS5 auth handshake
|
||||
if (data.length === 3 && data[0] === 0x05) {
|
||||
// Send "no auth required" response
|
||||
socket.write(Buffer.from([0x05, 0x00]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle SOCKS5 connect request
|
||||
if (data.length >= 4 && data[0] === 0x05 && data[1] === 0x01) {
|
||||
// Send success response with dummy bind address
|
||||
const response = Buffer.from([
|
||||
0x05, 0x00, 0x00, 0x01, // VER, REP, RSV, ATYP
|
||||
127, 0, 0, 1, // Bind IP (127.0.0.1)
|
||||
0x1F, 0x90 // Bind port (8080)
|
||||
]);
|
||||
socket.write(response);
|
||||
|
||||
// Now proxy data to the target HTTP server
|
||||
const targetSocket = net.connect(${httpPort}, '127.0.0.1');
|
||||
socket.pipe(targetSocket);
|
||||
targetSocket.pipe(socket);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', console.error);
|
||||
});
|
||||
|
||||
server.listen(${socksPort}, () => {
|
||||
console.log('Mock SOCKS server listening on port ${socksPort}');
|
||||
});
|
||||
`],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
// Start a simple HTTP server for testing
|
||||
using httpServer = Bun.serve({
|
||||
port: httpPort,
|
||||
fetch(req) {
|
||||
if (req.url.endsWith("/test")) {
|
||||
return new Response("Hello from HTTP server via SOCKS");
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// Wait a bit for servers to start
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (mockSocksServer) {
|
||||
mockSocksServer.kill();
|
||||
}
|
||||
});
|
||||
|
||||
test("should connect through SOCKS5 proxy via http_proxy environment variable", async () => {
|
||||
const originalProxy = process.env.http_proxy;
|
||||
|
||||
try {
|
||||
process.env.http_proxy = `socks5://127.0.0.1:${socksPort}`;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${httpPort}/test`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("Hello from HTTP server via SOCKS");
|
||||
} finally {
|
||||
if (originalProxy !== undefined) {
|
||||
process.env.http_proxy = originalProxy;
|
||||
} else {
|
||||
delete process.env.http_proxy;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should connect through SOCKS5h proxy via http_proxy environment variable", async () => {
|
||||
const originalProxy = process.env.http_proxy;
|
||||
|
||||
try {
|
||||
process.env.http_proxy = `socks5h://127.0.0.1:${socksPort}`;
|
||||
|
||||
const response = await fetch(`http://localhost:${httpPort}/test`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("Hello from HTTP server via SOCKS");
|
||||
} finally {
|
||||
if (originalProxy !== undefined) {
|
||||
process.env.http_proxy = originalProxy;
|
||||
} else {
|
||||
delete process.env.http_proxy;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
130
test/js/bun/http/socks-proxy.test.ts
Normal file
130
test/js/bun/http/socks-proxy.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { spawn, type ChildProcess } from "bun";
|
||||
|
||||
describe("SOCKS proxy", () => {
|
||||
let mockSocksServer: ChildProcess;
|
||||
let socksPort: number;
|
||||
let httpPort: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Find available ports
|
||||
socksPort = 9050 + Math.floor(Math.random() * 1000);
|
||||
httpPort = 8080 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Start a mock SOCKS5 server for testing
|
||||
mockSocksServer = spawn({
|
||||
cmd: ["node", "-e", `
|
||||
const net = require('net');
|
||||
const server = net.createServer((socket) => {
|
||||
console.log('SOCKS connection received');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
console.log('SOCKS data:', data.toString('hex'));
|
||||
|
||||
// Handle SOCKS5 auth handshake
|
||||
if (data.length === 3 && data[0] === 0x05) {
|
||||
// Send "no auth required" response
|
||||
socket.write(Buffer.from([0x05, 0x00]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle SOCKS5 connect request
|
||||
if (data.length >= 4 && data[0] === 0x05 && data[1] === 0x01) {
|
||||
// Send success response with dummy bind address
|
||||
const response = Buffer.from([
|
||||
0x05, 0x00, 0x00, 0x01, // VER, REP, RSV, ATYP
|
||||
127, 0, 0, 1, // Bind IP (127.0.0.1)
|
||||
0x1F, 0x90 // Bind port (8080)
|
||||
]);
|
||||
socket.write(response);
|
||||
|
||||
// Now proxy data to the target HTTP server
|
||||
const targetSocket = net.connect(${httpPort}, '127.0.0.1');
|
||||
socket.pipe(targetSocket);
|
||||
targetSocket.pipe(socket);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', console.error);
|
||||
});
|
||||
|
||||
server.listen(${socksPort}, () => {
|
||||
console.log('Mock SOCKS server listening on port ${socksPort}');
|
||||
});
|
||||
`],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
// Start a simple HTTP server for testing
|
||||
using httpServer = Bun.serve({
|
||||
port: httpPort,
|
||||
fetch(req) {
|
||||
if (req.url.endsWith("/test")) {
|
||||
return new Response("Hello from HTTP server");
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// Wait a bit for servers to start
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (mockSocksServer) {
|
||||
mockSocksServer.kill();
|
||||
}
|
||||
});
|
||||
|
||||
test("should connect through SOCKS5 proxy", async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${httpPort}/test`, {
|
||||
// @ts-ignore - This might not be typed yet
|
||||
proxy: `socks5://127.0.0.1:${socksPort}`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("Hello from HTTP server");
|
||||
});
|
||||
|
||||
test("should connect through SOCKS5h proxy", async () => {
|
||||
const response = await fetch(`http://localhost:${httpPort}/test`, {
|
||||
// @ts-ignore - This might not be typed yet
|
||||
proxy: `socks5h://127.0.0.1:${socksPort}`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("Hello from HTTP server");
|
||||
});
|
||||
|
||||
test("should handle SOCKS proxy via environment variable", async () => {
|
||||
const originalProxy = process.env.http_proxy;
|
||||
|
||||
try {
|
||||
process.env.http_proxy = `socks5://127.0.0.1:${socksPort}`;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${httpPort}/test`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("Hello from HTTP server");
|
||||
} finally {
|
||||
if (originalProxy !== undefined) {
|
||||
process.env.http_proxy = originalProxy;
|
||||
} else {
|
||||
delete process.env.http_proxy;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle SOCKS proxy connection failure", async () => {
|
||||
const invalidPort = 65000;
|
||||
|
||||
const promise = fetch(`http://127.0.0.1:${httpPort}/test`, {
|
||||
// @ts-ignore
|
||||
proxy: `socks5://127.0.0.1:${invalidPort}`,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user