mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
Compare commits
2 Commits
claude/fix
...
claude/soc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
762b4afbe8 | ||
|
|
ab6ea1a382 |
134
src/http.zig
134
src/http.zig
@@ -129,7 +129,12 @@ pub fn onOpen(
|
||||
) !void {
|
||||
if (comptime Environment.allow_assert) {
|
||||
if (client.http_proxy) |proxy| {
|
||||
assert(is_ssl == proxy.isHTTPS());
|
||||
// For SOCKS5, SSL is based on target URL, not proxy URL
|
||||
if (proxy.isSOCKS5()) {
|
||||
assert(is_ssl == client.url.isHTTPS());
|
||||
} else {
|
||||
assert(is_ssl == proxy.isHTTPS());
|
||||
}
|
||||
} else {
|
||||
assert(is_ssl == client.url.isHTTPS());
|
||||
}
|
||||
@@ -150,7 +155,10 @@ pub fn onOpen(
|
||||
if (!ssl_ptr.isInitFinished()) {
|
||||
var _hostname = client.hostname orelse client.url.hostname;
|
||||
if (client.http_proxy) |proxy| {
|
||||
_hostname = proxy.hostname;
|
||||
// For SOCKS5, use target hostname for SSL, not proxy hostname
|
||||
if (!proxy.isSOCKS5()) {
|
||||
_hostname = proxy.hostname;
|
||||
}
|
||||
}
|
||||
|
||||
var hostname: [:0]const u8 = "";
|
||||
@@ -187,6 +195,18 @@ pub fn firstCall(
|
||||
}
|
||||
}
|
||||
|
||||
// Start SOCKS5 tunnel if needed
|
||||
if (client.http_proxy) |proxy| {
|
||||
if (proxy.isSOCKS5()) {
|
||||
client.startSOCKS5Tunnel(is_ssl, socket) catch |err| {
|
||||
log("Failed to start SOCKS5 tunnel: {s}", .{@errorName(err)});
|
||||
client.closeAndFail(err, is_ssl, socket);
|
||||
return;
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (client.state.request_stage) {
|
||||
.opened, .pending => {
|
||||
client.onWritable(true, comptime is_ssl, socket);
|
||||
@@ -415,12 +435,13 @@ pub const Flags = packed struct(u16) {
|
||||
force_last_modified: bool = false,
|
||||
redirected: bool = false,
|
||||
proxy_tunneling: bool = false,
|
||||
socks5_tunneling: bool = false,
|
||||
reject_unauthorized: bool = true,
|
||||
is_preconnect_only: bool = false,
|
||||
is_streaming_request_body: bool = false,
|
||||
defer_fail_until_connecting_is_complete: bool = false,
|
||||
upgrade_state: HTTPUpgradeState = .none,
|
||||
_padding: u3 = 0,
|
||||
_padding: u2 = 0,
|
||||
};
|
||||
|
||||
// TODO: reduce the size of this struct
|
||||
@@ -452,6 +473,7 @@ request_content_len_buf: ["-4294967295".len]u8 = undefined,
|
||||
http_proxy: ?URL = null,
|
||||
proxy_authorization: ?[]u8 = null,
|
||||
proxy_tunnel: ?*ProxyTunnel = null,
|
||||
socks5_tunnel: ?*SOCKS5Tunnel = null,
|
||||
signals: Signals = .{},
|
||||
async_http_id: u32 = 0,
|
||||
hostname: ?[]u8 = null,
|
||||
@@ -470,6 +492,10 @@ pub fn deinit(this: *HTTPClient) void {
|
||||
this.proxy_tunnel = null;
|
||||
tunnel.detachAndDeref();
|
||||
}
|
||||
if (this.socks5_tunnel) |tunnel| {
|
||||
this.socks5_tunnel = null;
|
||||
tunnel.deref();
|
||||
}
|
||||
this.unix_socket_path.deinit();
|
||||
this.unix_socket_path = jsc.ZigString.Slice.empty;
|
||||
}
|
||||
@@ -772,6 +798,11 @@ pub fn doRedirect(
|
||||
/// **Not thread safe while request is in-flight**
|
||||
pub fn isHTTPS(this: *HTTPClient) bool {
|
||||
if (this.http_proxy) |proxy| {
|
||||
// For SOCKS5 proxies, SSL is based on target URL, not proxy URL
|
||||
if (proxy.isSOCKS5()) {
|
||||
return this.url.isHTTPS();
|
||||
}
|
||||
// For HTTP CONNECT proxies, SSL is based on proxy URL
|
||||
if (proxy.isHTTPS()) {
|
||||
return true;
|
||||
}
|
||||
@@ -926,8 +957,18 @@ 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.isSOCKS5()) {
|
||||
log("start socks5 tunneling", .{});
|
||||
// SOCKS5 proxy - handshake will be done separately
|
||||
// Don't send HTTP request yet
|
||||
this.flags.socks5_tunneling = true;
|
||||
return .{
|
||||
.has_sent_headers = false,
|
||||
.has_sent_body = false,
|
||||
.try_sending_more_data = false,
|
||||
};
|
||||
} else if (this.url.isHTTPS()) {
|
||||
log("start proxy tunneling (https proxy)", .{});
|
||||
//DO the tunneling!
|
||||
this.flags.proxy_tunneling = true;
|
||||
@@ -1349,6 +1390,79 @@ fn startProxyHandshake(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTP
|
||||
ProxyTunnel.start(this, is_ssl, socket, ssl_options, start_payload);
|
||||
}
|
||||
|
||||
/// Start SOCKS5 tunnel handshake
|
||||
fn startSOCKS5Tunnel(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) !void {
|
||||
log("startSOCKS5Tunnel", .{});
|
||||
|
||||
const proxy = this.http_proxy orelse return error.NoProxy;
|
||||
|
||||
// Extract username/password from proxy URL if present
|
||||
var username: ?[]const u8 = null;
|
||||
var password: ?[]const u8 = null;
|
||||
if (proxy.username.len > 0) {
|
||||
username = proxy.username;
|
||||
if (proxy.password.len > 0) {
|
||||
password = proxy.password;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we should resolve DNS on the proxy (socks5h://)
|
||||
const resolve_on_proxy = strings.eqlComptime(proxy.protocol, "socks5h");
|
||||
|
||||
// Create SOCKS5 tunnel configuration
|
||||
const config = SOCKS5Tunnel.Config{
|
||||
.target_hostname = this.url.hostname,
|
||||
.target_port = this.url.getPortAuto(),
|
||||
.username = username,
|
||||
.password = password,
|
||||
.resolve_on_proxy = resolve_on_proxy,
|
||||
};
|
||||
|
||||
// Create the tunnel
|
||||
const tunnel = try SOCKS5Tunnel.create(bun.default_allocator, config);
|
||||
errdefer tunnel.deref();
|
||||
|
||||
this.socks5_tunnel = tunnel;
|
||||
|
||||
// Start the SOCKS5 handshake
|
||||
try tunnel.start(is_ssl, socket, this);
|
||||
}
|
||||
|
||||
/// Called when SOCKS5 tunnel is successfully established
|
||||
pub fn onSOCKS5Connected(this: *HTTPClient, comptime is_ssl: bool) void {
|
||||
log("onSOCKS5Connected", .{});
|
||||
|
||||
this.flags.socks5_tunneling = false;
|
||||
this.state.request_stage = .opened;
|
||||
|
||||
// Get the socket from the SOCKS5 tunnel
|
||||
if (this.socks5_tunnel) |tunnel| {
|
||||
// Detach tunnel from socket - we'll use it directly now
|
||||
defer tunnel.detachSocket();
|
||||
|
||||
// Start sending the actual HTTP request
|
||||
if (is_ssl) {
|
||||
switch (tunnel.socket) {
|
||||
.ssl => |socket| {
|
||||
this.onWritable(true, true, socket);
|
||||
},
|
||||
else => {
|
||||
log("SOCKS5 tunnel expected SSL socket but got none", .{});
|
||||
},
|
||||
}
|
||||
} else {
|
||||
switch (tunnel.socket) {
|
||||
.tcp => |socket| {
|
||||
this.onWritable(true, false, socket);
|
||||
},
|
||||
else => {
|
||||
log("SOCKS5 tunnel expected TCP socket but got none", .{});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fn handleShortRead(
|
||||
this: *HTTPClient,
|
||||
comptime is_ssl: bool,
|
||||
@@ -1555,6 +1669,15 @@ pub fn onData(
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.socks5_tunnel) |tunnel| {
|
||||
// SOCKS5 tunnel is still in handshake phase
|
||||
if (this.flags.socks5_tunneling) {
|
||||
this.setTimeout(socket, 5);
|
||||
tunnel.onData(is_ssl, socket, incoming_data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (this.state.response_stage) {
|
||||
.pending, .headers => {
|
||||
this.handleOnDataHeaders(is_ssl, incoming_data, ctx, socket);
|
||||
@@ -2534,6 +2657,7 @@ const string = []const u8;
|
||||
|
||||
const HTTPCertError = @import("./http/HTTPCertError.zig");
|
||||
const ProxyTunnel = @import("./http/ProxyTunnel.zig");
|
||||
const SOCKS5Tunnel = @import("./http/SOCKS5Tunnel.zig");
|
||||
const std = @import("std");
|
||||
const URL = @import("./url.zig").URL;
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !NewH
|
||||
client.flags.disable_keepalive = true;
|
||||
if (client.http_proxy) |url| {
|
||||
// https://github.com/oven-sh/bun/issues/11343
|
||||
if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) {
|
||||
if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http") or strings.eqlComptime(url.protocol, "socks5") or strings.eqlComptime(url.protocol, "socks5h")) {
|
||||
return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto());
|
||||
}
|
||||
return error.UnsupportedProxyProtocol;
|
||||
@@ -265,7 +265,7 @@ pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !NewH
|
||||
if (client.http_proxy) |url| {
|
||||
if (url.href.len > 0) {
|
||||
// https://github.com/oven-sh/bun/issues/11343
|
||||
if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) {
|
||||
if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http") or strings.eqlComptime(url.protocol, "socks5") or strings.eqlComptime(url.protocol, "socks5h")) {
|
||||
return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto());
|
||||
}
|
||||
return error.UnsupportedProxyProtocol;
|
||||
|
||||
808
src/http/SOCKS5Tunnel.zig
Normal file
808
src/http/SOCKS5Tunnel.zig
Normal file
@@ -0,0 +1,808 @@
|
||||
/// SOCKS5 Tunnel Implementation for Bun
|
||||
///
|
||||
/// This module provides a complete SOCKS5 proxy client implementation that can be reused
|
||||
/// across different parts of Bun. It implements RFC 1928 (SOCKS5 Protocol) and RFC 1929
|
||||
/// (Username/Password Authentication).
|
||||
///
|
||||
/// ## SOCKS5 Protocol Overview
|
||||
///
|
||||
/// SOCKS5 is a protocol that allows a client to establish a TCP connection through a proxy server.
|
||||
/// Unlike HTTP CONNECT proxies which work at the HTTP protocol layer, SOCKS5 works at the TCP layer,
|
||||
/// making it protocol-agnostic - it can tunnel any TCP-based protocol (HTTP, HTTPS, WebSocket, etc.)
|
||||
///
|
||||
/// ## Protocol Flow
|
||||
///
|
||||
/// 1. **Client Greeting**: Client sends supported authentication methods
|
||||
/// ```
|
||||
/// +----+----------+----------+
|
||||
/// |VER | NMETHODS | METHODS |
|
||||
/// +----+----------+----------+
|
||||
/// | 1 | 1 | 1 to 255 |
|
||||
/// +----+----------+----------+
|
||||
/// ```
|
||||
///
|
||||
/// 2. **Server Choice**: Server selects an authentication method
|
||||
/// ```
|
||||
/// +----+--------+
|
||||
/// |VER | METHOD |
|
||||
/// +----+--------+
|
||||
/// | 1 | 1 |
|
||||
/// +----+--------+
|
||||
/// ```
|
||||
///
|
||||
/// 3. **Authentication** (if required): Perform selected authentication
|
||||
/// For username/password (method 0x02):
|
||||
/// ```
|
||||
/// Client -> Server:
|
||||
/// +----+------+----------+------+----------+
|
||||
/// |VER | ULEN | UNAME | PLEN | PASSWD |
|
||||
/// +----+------+----------+------+----------+
|
||||
/// | 1 | 1 | 1 to 255 | 1 | 1 to 255 |
|
||||
/// +----+------+----------+------+----------+
|
||||
///
|
||||
/// Server -> Client:
|
||||
/// +----+--------+
|
||||
/// |VER | STATUS |
|
||||
/// +----+--------+
|
||||
/// | 1 | 1 |
|
||||
/// +----+--------+
|
||||
/// ```
|
||||
///
|
||||
/// 4. **Connection Request**: Client requests a connection to target
|
||||
/// ```
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// ```
|
||||
///
|
||||
/// 5. **Connection Reply**: Server responds with connection status
|
||||
/// ```
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// ```
|
||||
///
|
||||
/// 6. **Data Transfer**: After successful connection, client and server exchange data normally
|
||||
///
|
||||
/// ## Usage Example
|
||||
///
|
||||
/// ```zig
|
||||
/// const tunnel = try SOCKS5Tunnel.create(allocator, .{
|
||||
/// .target_hostname = "example.com",
|
||||
/// .target_port = 443,
|
||||
/// .username = "user",
|
||||
/// .password = "pass",
|
||||
/// });
|
||||
/// defer tunnel.deref();
|
||||
///
|
||||
/// // After socket is connected to SOCKS5 proxy
|
||||
/// tunnel.start(is_ssl, socket);
|
||||
/// ```
|
||||
const SOCKS5Tunnel = @This();
|
||||
|
||||
const log = Output.scoped(.socks5_tunnel, .visible);
|
||||
|
||||
// SOCKS5 Protocol Constants (RFC 1928)
|
||||
|
||||
/// SOCKS protocol version 5
|
||||
const SOCKS5_VERSION: u8 = 0x05;
|
||||
|
||||
/// Username/password authentication protocol version (RFC 1929)
|
||||
const USERNAME_PASSWORD_VERSION: u8 = 0x01;
|
||||
|
||||
/// SOCKS5 Authentication Methods
|
||||
const AuthMethod = enum(u8) {
|
||||
/// No authentication required
|
||||
no_auth = 0x00,
|
||||
/// GSSAPI authentication (not implemented)
|
||||
gssapi = 0x01,
|
||||
/// Username/password authentication (RFC 1929)
|
||||
username_password = 0x02,
|
||||
/// No acceptable methods
|
||||
no_acceptable = 0xFF,
|
||||
};
|
||||
|
||||
/// SOCKS5 Commands
|
||||
const Command = enum(u8) {
|
||||
/// Establish a TCP/IP stream connection
|
||||
connect = 0x01,
|
||||
/// Establish a TCP/IP port binding (not implemented)
|
||||
bind = 0x02,
|
||||
/// Associate a UDP port (not implemented)
|
||||
udp_associate = 0x03,
|
||||
};
|
||||
|
||||
/// SOCKS5 Address Types
|
||||
const AddressType = enum(u8) {
|
||||
/// IPv4 address (4 bytes)
|
||||
ipv4 = 0x01,
|
||||
/// Domain name (1 byte length + domain name)
|
||||
domain = 0x03,
|
||||
/// IPv6 address (16 bytes)
|
||||
ipv6 = 0x04,
|
||||
};
|
||||
|
||||
/// SOCKS5 Reply Codes
|
||||
const ReplyCode = enum(u8) {
|
||||
/// Succeeded
|
||||
succeeded = 0x00,
|
||||
/// General SOCKS server failure
|
||||
server_failure = 0x01,
|
||||
/// Connection not allowed by ruleset
|
||||
not_allowed = 0x02,
|
||||
/// Network unreachable
|
||||
network_unreachable = 0x03,
|
||||
/// Host unreachable
|
||||
host_unreachable = 0x04,
|
||||
/// Connection refused
|
||||
connection_refused = 0x05,
|
||||
/// TTL expired
|
||||
ttl_expired = 0x06,
|
||||
/// Command not supported
|
||||
command_not_supported = 0x07,
|
||||
/// Address type not supported
|
||||
address_type_not_supported = 0x08,
|
||||
_,
|
||||
|
||||
/// Convert reply code to error
|
||||
pub fn toError(self: ReplyCode) anyerror {
|
||||
return switch (self) {
|
||||
.succeeded => error.SOCKS5Succeeded,
|
||||
.server_failure => error.SOCKS5ServerFailure,
|
||||
.not_allowed => error.SOCKS5NotAllowed,
|
||||
.network_unreachable => error.SOCKS5NetworkUnreachable,
|
||||
.host_unreachable => error.SOCKS5HostUnreachable,
|
||||
.connection_refused => error.SOCKS5ConnectionRefused,
|
||||
.ttl_expired => error.SOCKS5TTLExpired,
|
||||
.command_not_supported => error.SOCKS5CommandNotSupported,
|
||||
.address_type_not_supported => error.SOCKS5AddressTypeNotSupported,
|
||||
else => error.SOCKS5UnknownError,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn toString(self: ReplyCode) []const u8 {
|
||||
return switch (self) {
|
||||
.succeeded => "succeeded",
|
||||
.server_failure => "general server failure",
|
||||
.not_allowed => "connection not allowed by ruleset",
|
||||
.network_unreachable => "network unreachable",
|
||||
.host_unreachable => "host unreachable",
|
||||
.connection_refused => "connection refused",
|
||||
.ttl_expired => "TTL expired",
|
||||
.command_not_supported => "command not supported",
|
||||
.address_type_not_supported => "address type not supported",
|
||||
else => "unknown error",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// SOCKS5 Handshake State Machine
|
||||
const HandshakeState = enum {
|
||||
/// Initial state - need to send greeting
|
||||
initial,
|
||||
/// Waiting for server to select authentication method
|
||||
awaiting_auth_method,
|
||||
/// Performing username/password authentication
|
||||
authenticating,
|
||||
/// Waiting for authentication response
|
||||
awaiting_auth_response,
|
||||
/// Sending connection request
|
||||
sending_connect_request,
|
||||
/// Waiting for connection response
|
||||
awaiting_connect_response,
|
||||
/// Handshake complete, ready for data transfer
|
||||
connected,
|
||||
/// Handshake failed
|
||||
failed,
|
||||
};
|
||||
|
||||
/// Configuration for SOCKS5 tunnel
|
||||
pub const Config = struct {
|
||||
/// Target hostname to connect to
|
||||
target_hostname: []const u8,
|
||||
/// Target port to connect to
|
||||
target_port: u16,
|
||||
/// Optional username for authentication
|
||||
username: ?[]const u8 = null,
|
||||
/// Optional password for authentication
|
||||
password: ?[]const u8 = null,
|
||||
/// Whether to resolve DNS on the proxy server (socks5h://)
|
||||
/// If true, always use domain address type
|
||||
/// If false, may resolve locally and send IP address
|
||||
resolve_on_proxy: bool = true,
|
||||
};
|
||||
|
||||
/// Reference counting for memory management
|
||||
const RefCount = bun.ptr.RefCount(@This(), "ref_count", SOCKS5Tunnel.deinit, .{});
|
||||
pub const ref = SOCKS5Tunnel.RefCount.ref;
|
||||
pub const deref = SOCKS5Tunnel.RefCount.deref;
|
||||
|
||||
// Fields
|
||||
|
||||
/// Reference count for memory management
|
||||
ref_count: RefCount,
|
||||
|
||||
/// Current state of the SOCKS5 handshake
|
||||
state: HandshakeState = .initial,
|
||||
|
||||
/// Configuration for this tunnel
|
||||
config: Config,
|
||||
|
||||
/// Buffer for building outgoing SOCKS5 messages
|
||||
write_buffer: bun.io.StreamBuffer = .{},
|
||||
|
||||
/// Buffer for accumulating incoming SOCKS5 responses
|
||||
read_buffer: std.ArrayList(u8),
|
||||
|
||||
/// The socket we're tunneling through
|
||||
socket: union(enum) {
|
||||
tcp: NewHTTPContext(false).HTTPSocket,
|
||||
ssl: NewHTTPContext(true).HTTPSocket,
|
||||
none: void,
|
||||
} = .{ .none = {} },
|
||||
|
||||
/// Error that caused shutdown (if any)
|
||||
shutdown_err: anyerror = error.ConnectionClosed,
|
||||
|
||||
/// Pointer to the HTTP client (for callbacks)
|
||||
http_client: ?*HTTPClient = null,
|
||||
|
||||
/// Allocator for internal allocations
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
// Public API
|
||||
|
||||
/// Create a new SOCKS5 tunnel with the given configuration
|
||||
pub fn create(allocator: std.mem.Allocator, config: Config) !*SOCKS5Tunnel {
|
||||
const tunnel = try allocator.create(SOCKS5Tunnel);
|
||||
|
||||
// Validate configuration
|
||||
if (config.target_hostname.len == 0) {
|
||||
return error.InvalidHostname;
|
||||
}
|
||||
if (config.target_hostname.len > 255) {
|
||||
return error.HostnameTooLong;
|
||||
}
|
||||
if ((config.username != null) != (config.password != null)) {
|
||||
return error.IncompleteCredentials;
|
||||
}
|
||||
if (config.username) |username| {
|
||||
if (username.len == 0 or username.len > 255) {
|
||||
return error.InvalidUsername;
|
||||
}
|
||||
}
|
||||
if (config.password) |password| {
|
||||
if (password.len > 255) {
|
||||
return error.PasswordTooLong;
|
||||
}
|
||||
}
|
||||
|
||||
tunnel.* = .{
|
||||
.ref_count = .init(),
|
||||
.config = config,
|
||||
.read_buffer = std.ArrayList(u8).init(allocator),
|
||||
.allocator = allocator,
|
||||
};
|
||||
|
||||
return tunnel;
|
||||
}
|
||||
|
||||
/// Start the SOCKS5 handshake on the given socket
|
||||
pub fn start(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
http_client: *HTTPClient,
|
||||
) !void {
|
||||
log("SOCKS5Tunnel.start is_ssl={}", .{is_ssl});
|
||||
|
||||
this.http_client = http_client;
|
||||
|
||||
if (is_ssl) {
|
||||
this.socket = .{ .ssl = socket };
|
||||
} else {
|
||||
this.socket = .{ .tcp = socket };
|
||||
}
|
||||
|
||||
// Start by sending the greeting
|
||||
try this.sendGreeting(is_ssl, socket);
|
||||
}
|
||||
|
||||
/// Called when the socket becomes writable
|
||||
pub fn onWritable(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
) void {
|
||||
log("SOCKS5Tunnel.onWritable state={s}", .{@tagName(this.state)});
|
||||
|
||||
this.ref();
|
||||
defer this.deref();
|
||||
|
||||
// Flush any buffered data
|
||||
const encoded_data = this.write_buffer.slice();
|
||||
if (encoded_data.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const written = socket.write(encoded_data);
|
||||
if (written == encoded_data.len) {
|
||||
this.write_buffer.reset();
|
||||
} else {
|
||||
this.write_buffer.cursor += @intCast(written);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when data is received from the socket
|
||||
pub fn onData(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
data: []const u8,
|
||||
) void {
|
||||
log("SOCKS5Tunnel.onData state={s} len={}", .{ @tagName(this.state), data.len });
|
||||
|
||||
this.ref();
|
||||
defer this.deref();
|
||||
|
||||
if (data.len == 0) return;
|
||||
|
||||
// Append to read buffer
|
||||
this.read_buffer.appendSlice(data) catch {
|
||||
this.close(error.OutOfMemory);
|
||||
return;
|
||||
};
|
||||
|
||||
// Process based on current state
|
||||
switch (this.state) {
|
||||
.awaiting_auth_method => this.handleAuthMethodResponse(is_ssl, socket) catch |err| {
|
||||
this.close(err);
|
||||
},
|
||||
.awaiting_auth_response => this.handleAuthResponse(is_ssl, socket) catch |err| {
|
||||
this.close(err);
|
||||
},
|
||||
.awaiting_connect_response => this.handleConnectResponse(is_ssl, socket) catch |err| {
|
||||
this.close(err);
|
||||
},
|
||||
.connected => {
|
||||
// Handshake complete - this data is for the HTTP client
|
||||
log("SOCKS5Tunnel handshake complete, passing {} bytes to HTTP client", .{data.len});
|
||||
// This shouldn't happen - after connection, we should have detached
|
||||
// But if it does, just ignore it
|
||||
},
|
||||
else => {
|
||||
log("SOCKS5Tunnel unexpected data in state {s}", .{@tagName(this.state)});
|
||||
this.close(error.UnexpectedData);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the connection is closed
|
||||
pub fn onClose(this: *SOCKS5Tunnel) void {
|
||||
log("SOCKS5Tunnel.onClose state={s}", .{@tagName(this.state)});
|
||||
|
||||
if (this.state != .connected and this.state != .failed) {
|
||||
// Connection closed during handshake
|
||||
this.shutdown_err = error.SOCKS5HandshakeFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the tunnel with an error
|
||||
pub fn close(this: *SOCKS5Tunnel, err: anyerror) void {
|
||||
log("SOCKS5Tunnel.close err={s}", .{@errorName(err)});
|
||||
|
||||
this.state = .failed;
|
||||
this.shutdown_err = err;
|
||||
this.shutdown();
|
||||
}
|
||||
|
||||
/// Shutdown the tunnel
|
||||
pub fn shutdown(this: *SOCKS5Tunnel) void {
|
||||
log("SOCKS5Tunnel.shutdown", .{});
|
||||
|
||||
switch (this.socket) {
|
||||
.ssl => |socket| {
|
||||
socket.close(.normal);
|
||||
},
|
||||
.tcp => |socket| {
|
||||
socket.close(.normal);
|
||||
},
|
||||
.none => {},
|
||||
}
|
||||
|
||||
this.detachSocket();
|
||||
}
|
||||
|
||||
/// Detach the socket (called before transfer to HTTP client)
|
||||
pub fn detachSocket(this: *SOCKS5Tunnel) void {
|
||||
this.socket = .{ .none = {} };
|
||||
}
|
||||
|
||||
// Private Implementation
|
||||
|
||||
/// Send the initial greeting to the SOCKS5 server
|
||||
///
|
||||
/// Format:
|
||||
/// +----+----------+----------+
|
||||
/// |VER | NMETHODS | METHODS |
|
||||
/// +----+----------+----------+
|
||||
/// | 1 | 1 | 1 to 255 |
|
||||
/// +----+----------+----------+
|
||||
fn sendGreeting(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
) !void {
|
||||
log("SOCKS5Tunnel.sendGreeting", .{});
|
||||
|
||||
var greeting = std.ArrayList(u8).init(this.allocator);
|
||||
defer greeting.deinit();
|
||||
|
||||
// Version
|
||||
try greeting.append(SOCKS5_VERSION);
|
||||
|
||||
// Determine which authentication methods to offer
|
||||
const has_credentials = this.config.username != null;
|
||||
|
||||
if (has_credentials) {
|
||||
// Offer both no-auth and username/password
|
||||
try greeting.append(2); // NMETHODS
|
||||
try greeting.append(@intFromEnum(AuthMethod.no_auth));
|
||||
try greeting.append(@intFromEnum(AuthMethod.username_password));
|
||||
} else {
|
||||
// Only offer no-auth
|
||||
try greeting.append(1); // NMETHODS
|
||||
try greeting.append(@intFromEnum(AuthMethod.no_auth));
|
||||
}
|
||||
|
||||
// Send the greeting
|
||||
const data = try greeting.toOwnedSlice();
|
||||
defer this.allocator.free(data);
|
||||
|
||||
const written = socket.write(data);
|
||||
if (written != data.len) {
|
||||
return error.SOCKS5WriteIncomplete;
|
||||
}
|
||||
|
||||
this.state = .awaiting_auth_method;
|
||||
}
|
||||
|
||||
/// Handle the authentication method selection response
|
||||
///
|
||||
/// Format:
|
||||
/// +----+--------+
|
||||
/// |VER | METHOD |
|
||||
/// +----+--------+
|
||||
/// | 1 | 1 |
|
||||
/// +----+--------+
|
||||
fn handleAuthMethodResponse(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
) !void {
|
||||
const buffer = this.read_buffer.items;
|
||||
|
||||
// Need at least 2 bytes
|
||||
if (buffer.len < 2) {
|
||||
return; // Wait for more data
|
||||
}
|
||||
|
||||
const version = buffer[0];
|
||||
const method = buffer[1];
|
||||
|
||||
// Remove processed bytes
|
||||
this.read_buffer.replaceRange(0, 2, &.{}) catch unreachable;
|
||||
|
||||
log("SOCKS5Tunnel.handleAuthMethodResponse version={} method={}", .{ version, method });
|
||||
|
||||
if (version != SOCKS5_VERSION) {
|
||||
return error.SOCKS5InvalidVersion;
|
||||
}
|
||||
|
||||
const auth_method: AuthMethod = @enumFromInt(method);
|
||||
|
||||
switch (auth_method) {
|
||||
.no_auth => {
|
||||
// No authentication required, proceed to connect
|
||||
try this.sendConnectRequest(is_ssl, socket);
|
||||
},
|
||||
.username_password => {
|
||||
// Server wants username/password authentication
|
||||
if (this.config.username == null) {
|
||||
return error.SOCKS5AuthenticationRequired;
|
||||
}
|
||||
try this.sendUsernamePassword(is_ssl, socket);
|
||||
},
|
||||
.no_acceptable => {
|
||||
return error.SOCKS5NoAcceptableMethods;
|
||||
},
|
||||
else => {
|
||||
log("SOCKS5 server requested unsupported auth method: {}", .{method});
|
||||
return error.SOCKS5UnsupportedAuthMethod;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Send username/password authentication (RFC 1929)
|
||||
///
|
||||
/// Format:
|
||||
/// +----+------+----------+------+----------+
|
||||
/// |VER | ULEN | UNAME | PLEN | PASSWD |
|
||||
/// +----+------+----------+------+----------+
|
||||
/// | 1 | 1 | 1 to 255 | 1 | 1 to 255 |
|
||||
/// +----+------+----------+------+----------+
|
||||
fn sendUsernamePassword(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
) !void {
|
||||
log("SOCKS5Tunnel.sendUsernamePassword", .{});
|
||||
|
||||
const username = this.config.username.?;
|
||||
const password = this.config.password.?;
|
||||
|
||||
var auth_data = std.ArrayList(u8).init(this.allocator);
|
||||
defer auth_data.deinit();
|
||||
|
||||
// Version (for username/password auth sub-protocol)
|
||||
try auth_data.append(USERNAME_PASSWORD_VERSION);
|
||||
|
||||
// Username
|
||||
try auth_data.append(@intCast(username.len));
|
||||
try auth_data.appendSlice(username);
|
||||
|
||||
// Password
|
||||
try auth_data.append(@intCast(password.len));
|
||||
try auth_data.appendSlice(password);
|
||||
|
||||
const data = try auth_data.toOwnedSlice();
|
||||
defer this.allocator.free(data);
|
||||
|
||||
const written = socket.write(data);
|
||||
if (written != data.len) {
|
||||
return error.SOCKS5WriteIncomplete;
|
||||
}
|
||||
|
||||
this.state = .awaiting_auth_response;
|
||||
}
|
||||
|
||||
/// Handle username/password authentication response
|
||||
///
|
||||
/// Format:
|
||||
/// +----+--------+
|
||||
/// |VER | STATUS |
|
||||
/// +----+--------+
|
||||
/// | 1 | 1 |
|
||||
/// +----+--------+
|
||||
fn handleAuthResponse(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
) !void {
|
||||
const buffer = this.read_buffer.items;
|
||||
|
||||
// Need at least 2 bytes
|
||||
if (buffer.len < 2) {
|
||||
return; // Wait for more data
|
||||
}
|
||||
|
||||
const version = buffer[0];
|
||||
const status = buffer[1];
|
||||
|
||||
// Remove processed bytes
|
||||
this.read_buffer.replaceRange(0, 2, &.{}) catch unreachable;
|
||||
|
||||
log("SOCKS5Tunnel.handleAuthResponse version={} status={}", .{ version, status });
|
||||
|
||||
if (version != USERNAME_PASSWORD_VERSION) {
|
||||
return error.SOCKS5InvalidAuthVersion;
|
||||
}
|
||||
|
||||
if (status != 0) {
|
||||
return error.SOCKS5AuthenticationFailed;
|
||||
}
|
||||
|
||||
// Authentication successful, proceed to connect
|
||||
try this.sendConnectRequest(is_ssl, socket);
|
||||
}
|
||||
|
||||
/// Send connection request to the SOCKS5 server
|
||||
///
|
||||
/// Format:
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
///
|
||||
/// Where DST.ADDR format depends on ATYP:
|
||||
/// - ATYP = 0x01 (IPv4): 4 bytes
|
||||
/// - ATYP = 0x03 (Domain): 1 byte length + domain name
|
||||
/// - ATYP = 0x04 (IPv6): 16 bytes
|
||||
fn sendConnectRequest(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
) !void {
|
||||
log("SOCKS5Tunnel.sendConnectRequest target={s}:{}", .{ this.config.target_hostname, this.config.target_port });
|
||||
|
||||
var request = std.ArrayList(u8).init(this.allocator);
|
||||
defer request.deinit();
|
||||
|
||||
// Version
|
||||
try request.append(SOCKS5_VERSION);
|
||||
// Command (CONNECT)
|
||||
try request.append(@intFromEnum(Command.connect));
|
||||
// Reserved
|
||||
try request.append(0x00);
|
||||
|
||||
// Address type and address
|
||||
// Try to parse as IP address first
|
||||
const hostname = this.config.target_hostname;
|
||||
|
||||
if (!this.config.resolve_on_proxy and strings.isIPAddress(hostname)) {
|
||||
// Use IP address directly
|
||||
if (strings.indexOf(hostname, ":")) |_| {
|
||||
// IPv6 address
|
||||
var addr: [16]u8 = undefined;
|
||||
if (parseIPv6(hostname, &addr)) {
|
||||
try request.append(@intFromEnum(AddressType.ipv6));
|
||||
try request.appendSlice(&addr);
|
||||
} else |_| {
|
||||
// Failed to parse, use domain name
|
||||
try this.appendDomainAddress(&request, hostname);
|
||||
}
|
||||
} else {
|
||||
// IPv4 address
|
||||
var addr: [4]u8 = undefined;
|
||||
if (parseIPv4(hostname, &addr)) {
|
||||
try request.append(@intFromEnum(AddressType.ipv4));
|
||||
try request.appendSlice(&addr);
|
||||
} else |_| {
|
||||
// Failed to parse, use domain name
|
||||
try this.appendDomainAddress(&request, hostname);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use domain name (let proxy resolve DNS)
|
||||
try this.appendDomainAddress(&request, hostname);
|
||||
}
|
||||
|
||||
// Port (big-endian)
|
||||
const port_bytes = std.mem.toBytes(std.mem.nativeToBig(u16, this.config.target_port));
|
||||
try request.appendSlice(&port_bytes);
|
||||
|
||||
const data = try request.toOwnedSlice();
|
||||
defer this.allocator.free(data);
|
||||
|
||||
const written = socket.write(data);
|
||||
if (written != data.len) {
|
||||
return error.SOCKS5WriteIncomplete;
|
||||
}
|
||||
|
||||
this.state = .awaiting_connect_response;
|
||||
}
|
||||
|
||||
/// Helper to append domain name to request
|
||||
fn appendDomainAddress(this: *SOCKS5Tunnel, request: *std.ArrayList(u8), hostname: []const u8) !void {
|
||||
_ = this;
|
||||
try request.append(@intFromEnum(AddressType.domain));
|
||||
try request.append(@intCast(hostname.len));
|
||||
try request.appendSlice(hostname);
|
||||
}
|
||||
|
||||
/// Handle connection response from SOCKS5 server
|
||||
///
|
||||
/// Format:
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
/// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
/// +----+-----+-------+------+----------+----------+
|
||||
fn handleConnectResponse(
|
||||
this: *SOCKS5Tunnel,
|
||||
comptime is_ssl: bool,
|
||||
socket: NewHTTPContext(is_ssl).HTTPSocket,
|
||||
) !void {
|
||||
_ = socket;
|
||||
const buffer = this.read_buffer.items;
|
||||
|
||||
// Need at least 4 bytes to determine response
|
||||
if (buffer.len < 4) {
|
||||
return; // Wait for more data
|
||||
}
|
||||
|
||||
const version = buffer[0];
|
||||
const reply = buffer[1];
|
||||
// buffer[2] is reserved
|
||||
const atyp = buffer[3];
|
||||
|
||||
log("SOCKS5Tunnel.handleConnectResponse version={} reply={} atyp={}", .{ version, reply, atyp });
|
||||
|
||||
if (version != SOCKS5_VERSION) {
|
||||
return error.SOCKS5InvalidVersion;
|
||||
}
|
||||
|
||||
const reply_code: ReplyCode = @enumFromInt(reply);
|
||||
if (reply_code != .succeeded) {
|
||||
log("SOCKS5 connection failed: {s}", .{reply_code.toString()});
|
||||
return reply_code.toError();
|
||||
}
|
||||
|
||||
// Calculate how much data to skip based on address type
|
||||
const addr_type: AddressType = @enumFromInt(atyp);
|
||||
const total_response_len: usize = switch (addr_type) {
|
||||
.ipv4 => 4 + 4 + 2, // header + ipv4 + port
|
||||
.ipv6 => 4 + 16 + 2, // header + ipv6 + port
|
||||
.domain => blk: {
|
||||
if (buffer.len < 5) {
|
||||
return; // Wait for domain length byte
|
||||
}
|
||||
const domain_len = buffer[4];
|
||||
break :blk 4 + 1 + domain_len + 2; // header + len + domain + port
|
||||
},
|
||||
};
|
||||
|
||||
// Wait for complete response
|
||||
if (buffer.len < total_response_len) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove processed bytes
|
||||
this.read_buffer.replaceRange(0, total_response_len, &.{}) catch unreachable;
|
||||
|
||||
// Connection established!
|
||||
this.state = .connected;
|
||||
log("SOCKS5Tunnel handshake complete", .{});
|
||||
|
||||
// Notify HTTP client that tunnel is ready
|
||||
if (this.http_client) |client| {
|
||||
if (is_ssl) {
|
||||
client.onSOCKS5Connected(true);
|
||||
} else {
|
||||
client.onSOCKS5Connected(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse IPv4 address string to 4-byte array
|
||||
fn parseIPv4(address: []const u8, out: *[4]u8) !void {
|
||||
var iter = std.mem.splitScalar(u8, address, '.');
|
||||
var i: usize = 0;
|
||||
while (iter.next()) |part| : (i += 1) {
|
||||
if (i >= 4) return error.InvalidIPv4;
|
||||
const num = std.fmt.parseInt(u8, part, 10) catch return error.InvalidIPv4;
|
||||
out[i] = num;
|
||||
}
|
||||
if (i != 4) return error.InvalidIPv4;
|
||||
}
|
||||
|
||||
/// Parse IPv6 address string to 16-byte array
|
||||
fn parseIPv6(address: []const u8, out: *[16]u8) !void {
|
||||
// Simple implementation - use std.net.Address.parseIp6
|
||||
const parsed = std.net.Address.parseIp6(address, 0) catch return error.InvalidIPv6;
|
||||
@memcpy(out, &parsed.in6.sa.addr);
|
||||
}
|
||||
|
||||
/// Cleanup
|
||||
fn deinit(this: *SOCKS5Tunnel) void {
|
||||
log("SOCKS5Tunnel.deinit", .{});
|
||||
|
||||
this.detachSocket();
|
||||
this.write_buffer.deinit();
|
||||
this.read_buffer.deinit();
|
||||
this.allocator.destroy(this);
|
||||
}
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const bun = @import("bun");
|
||||
const Output = bun.Output;
|
||||
const strings = bun.strings;
|
||||
|
||||
const HTTPClient = bun.http;
|
||||
const NewHTTPContext = bun.http.NewHTTPContext;
|
||||
@@ -100,6 +100,11 @@ pub const URL = struct {
|
||||
return strings.eqlComptime(this.protocol, "http");
|
||||
}
|
||||
|
||||
pub inline fn isSOCKS5(this: *const URL) bool {
|
||||
return strings.eqlComptime(this.protocol, "socks5") or
|
||||
strings.eqlComptime(this.protocol, "socks5h");
|
||||
}
|
||||
|
||||
pub fn displayHostname(this: *const URL) string {
|
||||
if (this.hostname.len > 0) {
|
||||
return this.hostname;
|
||||
|
||||
602
test/js/bun/http/socks5-proxy.test.ts
Normal file
602
test/js/bun/http/socks5-proxy.test.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
/**
|
||||
* SOCKS5 Proxy Tests
|
||||
*
|
||||
* This test suite validates Bun's SOCKS5 proxy implementation against the spec:
|
||||
* - RFC 1928: SOCKS Protocol Version 5
|
||||
* - RFC 1929: Username/Password Authentication for SOCKS V5
|
||||
*
|
||||
* Test coverage includes:
|
||||
* 1. Basic SOCKS5 connection (no auth)
|
||||
* 2. Username/password authentication
|
||||
* 3. socks5:// vs socks5h:// (DNS resolution)
|
||||
* 4. HTTP and HTTPS through SOCKS5
|
||||
* 5. Error handling (auth failure, connection refused, etc.)
|
||||
* 6. Protocol compliance (proper handshake sequence)
|
||||
*/
|
||||
|
||||
import type { Server } from "bun";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tls as tlsCert } from "harness";
|
||||
import { once } from "node:events";
|
||||
import net from "node:net";
|
||||
|
||||
// SOCKS5 Protocol Constants (RFC 1928)
|
||||
const SOCKS5_VERSION = 0x05;
|
||||
const AUTH_NONE = 0x00;
|
||||
const AUTH_USERNAME_PASSWORD = 0x02;
|
||||
const AUTH_NO_ACCEPTABLE = 0xff;
|
||||
const CMD_CONNECT = 0x01;
|
||||
const ATYP_IPV4 = 0x01;
|
||||
const ATYP_DOMAIN = 0x03;
|
||||
const ATYP_IPV6 = 0x04;
|
||||
const REP_SUCCESS = 0x00;
|
||||
const REP_SERVER_FAILURE = 0x01;
|
||||
const REP_CONNECTION_REFUSED = 0x05;
|
||||
|
||||
interface SOCKS5ProxyOptions {
|
||||
requireAuth?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
failAuth?: boolean;
|
||||
failConnection?: boolean;
|
||||
logRequests?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SOCKS5 proxy server that implements RFC 1928
|
||||
*
|
||||
* This server properly handles:
|
||||
* - Method negotiation
|
||||
* - No authentication (0x00)
|
||||
* - Username/password authentication (0x02) per RFC 1929
|
||||
* - Connection requests (CONNECT command)
|
||||
* - All address types (IPv4, IPv6, domain names)
|
||||
*/
|
||||
async function createSOCKS5ProxyServer(options: SOCKS5ProxyOptions = {}) {
|
||||
const {
|
||||
requireAuth = false,
|
||||
username = "testuser",
|
||||
password = "testpass",
|
||||
failAuth = false,
|
||||
failConnection = false,
|
||||
logRequests = false,
|
||||
} = options;
|
||||
|
||||
const log: string[] = [];
|
||||
|
||||
const server = net.createServer(clientSocket => {
|
||||
let authenticated = !requireAuth;
|
||||
let currentStep: "greeting" | "auth" | "request" = "greeting";
|
||||
|
||||
clientSocket.on("data", data => {
|
||||
try {
|
||||
if (currentStep === "greeting") {
|
||||
// Step 1: Client greeting
|
||||
// +----+----------+----------+
|
||||
// |VER | NMETHODS | METHODS |
|
||||
// +----+----------+----------+
|
||||
// | 1 | 1 | 1 to 255 |
|
||||
// +----+----------+----------+
|
||||
const version = data[0];
|
||||
const nmethods = data[1];
|
||||
const methods = Array.from(data.slice(2, 2 + nmethods));
|
||||
|
||||
if (version !== SOCKS5_VERSION) {
|
||||
if (logRequests) log.push(`Invalid version: ${version}`);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (logRequests) log.push(`Greeting: methods=${methods.map(m => `0x${m.toString(16)}`).join(",")}`);
|
||||
|
||||
// Step 2: Server selects authentication method
|
||||
// +----+--------+
|
||||
// |VER | METHOD |
|
||||
// +----+--------+
|
||||
// | 1 | 1 |
|
||||
// +----+--------+
|
||||
let selectedMethod: number;
|
||||
if (requireAuth) {
|
||||
if (methods.includes(AUTH_USERNAME_PASSWORD)) {
|
||||
selectedMethod = AUTH_USERNAME_PASSWORD;
|
||||
currentStep = "auth";
|
||||
} else {
|
||||
selectedMethod = AUTH_NO_ACCEPTABLE;
|
||||
}
|
||||
} else {
|
||||
if (methods.includes(AUTH_NONE)) {
|
||||
selectedMethod = AUTH_NONE;
|
||||
currentStep = "request";
|
||||
authenticated = true;
|
||||
} else {
|
||||
selectedMethod = AUTH_NO_ACCEPTABLE;
|
||||
}
|
||||
}
|
||||
|
||||
const response = Buffer.from([SOCKS5_VERSION, selectedMethod]);
|
||||
clientSocket.write(response);
|
||||
|
||||
if (selectedMethod === AUTH_NO_ACCEPTABLE) {
|
||||
clientSocket.end();
|
||||
}
|
||||
} else if (currentStep === "auth") {
|
||||
// Step 3: Username/Password authentication (RFC 1929)
|
||||
// Client request:
|
||||
// +----+------+----------+------+----------+
|
||||
// |VER | ULEN | UNAME | PLEN | PASSWD |
|
||||
// +----+------+----------+------+----------+
|
||||
// | 1 | 1 | 1 to 255 | 1 | 1 to 255 |
|
||||
// +----+------+----------+------+----------+
|
||||
const authVersion = data[0];
|
||||
if (authVersion !== 0x01) {
|
||||
if (logRequests) log.push(`Invalid auth version: ${authVersion}`);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const ulen = data[1];
|
||||
const uname = data.slice(2, 2 + ulen).toString();
|
||||
const plen = data[2 + ulen];
|
||||
const passwd = data.slice(3 + ulen, 3 + ulen + plen).toString();
|
||||
|
||||
if (logRequests) log.push(`Auth: username=${uname}`);
|
||||
|
||||
// Server response:
|
||||
// +----+--------+
|
||||
// |VER | STATUS |
|
||||
// +----+--------+
|
||||
// | 1 | 1 |
|
||||
// +----+--------+
|
||||
// Status: 0x00 = success, non-zero = failure
|
||||
let authStatus: number;
|
||||
if (failAuth || uname !== username || passwd !== password) {
|
||||
authStatus = 0x01; // Auth failed
|
||||
if (logRequests) log.push(`Auth failed: expected ${username}/${password}, got ${uname}/${passwd}`);
|
||||
} else {
|
||||
authStatus = 0x00; // Auth success
|
||||
authenticated = true;
|
||||
currentStep = "request";
|
||||
}
|
||||
|
||||
const response = Buffer.from([0x01, authStatus]);
|
||||
clientSocket.write(response);
|
||||
|
||||
if (authStatus !== 0x00) {
|
||||
clientSocket.end();
|
||||
}
|
||||
} else if (currentStep === "request") {
|
||||
// Step 4: Connection request
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
if (!authenticated) {
|
||||
if (logRequests) log.push("Request without authentication");
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const version = data[0];
|
||||
const cmd = data[1];
|
||||
const atyp = data[3];
|
||||
|
||||
if (version !== SOCKS5_VERSION) {
|
||||
if (logRequests) log.push(`Invalid version in request: ${version}`);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd !== CMD_CONNECT) {
|
||||
if (logRequests) log.push(`Unsupported command: ${cmd}`);
|
||||
// Send command not supported response
|
||||
const response = Buffer.from([SOCKS5_VERSION, 0x07, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]);
|
||||
clientSocket.write(response);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse destination address based on type
|
||||
let destHost: string;
|
||||
let destPort: number;
|
||||
let addrEnd: number;
|
||||
|
||||
if (atyp === ATYP_IPV4) {
|
||||
// IPv4: 4 bytes
|
||||
destHost = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`;
|
||||
addrEnd = 8;
|
||||
} else if (atyp === ATYP_DOMAIN) {
|
||||
// Domain: 1 byte length + domain name
|
||||
const domainLen = data[4];
|
||||
destHost = data.slice(5, 5 + domainLen).toString();
|
||||
addrEnd = 5 + domainLen;
|
||||
} else if (atyp === ATYP_IPV6) {
|
||||
// IPv6: 16 bytes
|
||||
const ipv6Parts = [];
|
||||
for (let i = 0; i < 16; i += 2) {
|
||||
ipv6Parts.push(((data[4 + i] << 8) | data[5 + i]).toString(16));
|
||||
}
|
||||
destHost = ipv6Parts.join(":");
|
||||
addrEnd = 20;
|
||||
} else {
|
||||
if (logRequests) log.push(`Unsupported address type: ${atyp}`);
|
||||
// Send address type not supported response
|
||||
const response = Buffer.from([SOCKS5_VERSION, 0x08, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]);
|
||||
clientSocket.write(response);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Port is always 2 bytes, big-endian
|
||||
destPort = (data[addrEnd] << 8) | data[addrEnd + 1];
|
||||
|
||||
if (logRequests) log.push(`CONNECT ${destHost}:${destPort}`);
|
||||
|
||||
// Step 5: Server response
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
|
||||
if (failConnection) {
|
||||
// Simulate connection refused
|
||||
const response = Buffer.from([
|
||||
SOCKS5_VERSION,
|
||||
REP_CONNECTION_REFUSED,
|
||||
0x00,
|
||||
ATYP_IPV4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // BND.ADDR (0.0.0.0)
|
||||
0,
|
||||
0, // BND.PORT (0)
|
||||
]);
|
||||
clientSocket.write(response);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Establish connection to destination
|
||||
const destSocket = net.connect(destPort, destHost, () => {
|
||||
if (logRequests) log.push(`Connected to ${destHost}:${destPort}`);
|
||||
|
||||
// Send success response
|
||||
const response = Buffer.from([
|
||||
SOCKS5_VERSION,
|
||||
REP_SUCCESS,
|
||||
0x00,
|
||||
ATYP_IPV4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // BND.ADDR (could be actual bind address)
|
||||
0,
|
||||
0, // BND.PORT (could be actual bind port)
|
||||
]);
|
||||
clientSocket.write(response);
|
||||
|
||||
// Step 6: Data transfer - pipe data bidirectionally
|
||||
clientSocket.pipe(destSocket);
|
||||
destSocket.pipe(clientSocket);
|
||||
});
|
||||
|
||||
destSocket.on("error", err => {
|
||||
if (logRequests) log.push(`Connection error: ${err.message}`);
|
||||
// Send server failure response
|
||||
const response = Buffer.from([SOCKS5_VERSION, REP_SERVER_FAILURE, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]);
|
||||
clientSocket.write(response);
|
||||
clientSocket.end();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (logRequests) log.push(`Error: ${error}`);
|
||||
clientSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
// Ignore client errors
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0);
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address !== null ? address.port : 0;
|
||||
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
url: `socks5://localhost:${port}`,
|
||||
urlWithAuth: requireAuth ? `socks5://${username}:${password}@localhost:${port}` : `socks5://localhost:${port}`,
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
// Test servers
|
||||
let httpServer: Server;
|
||||
let httpsServer: Server;
|
||||
let socks5Server: Awaited<ReturnType<typeof createSOCKS5ProxyServer>>;
|
||||
let socks5AuthServer: Awaited<ReturnType<typeof createSOCKS5ProxyServer>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
// HTTP server for testing
|
||||
httpServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
return new Response(`HTTP response from ${req.url}`, {
|
||||
headers: { "X-Test": "http" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// HTTPS server for testing
|
||||
httpsServer = Bun.serve({
|
||||
port: 0,
|
||||
tls: tlsCert,
|
||||
fetch(req) {
|
||||
return new Response(`HTTPS response from ${req.url}`, {
|
||||
headers: { "X-Test": "https" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// SOCKS5 proxy without authentication
|
||||
socks5Server = await createSOCKS5ProxyServer({
|
||||
logRequests: true,
|
||||
});
|
||||
|
||||
// SOCKS5 proxy with username/password authentication
|
||||
socks5AuthServer = await createSOCKS5ProxyServer({
|
||||
requireAuth: true,
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
logRequests: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
httpServer?.stop(true);
|
||||
httpsServer?.stop(true);
|
||||
socks5Server?.server.close();
|
||||
socks5AuthServer?.server.close();
|
||||
});
|
||||
|
||||
describe("SOCKS5 Proxy - Basic Functionality", () => {
|
||||
test("should connect through SOCKS5 proxy without authentication (HTTP)", async () => {
|
||||
const response = await fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: socks5Server.url,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
expect(text).toContain("HTTP response");
|
||||
expect(socks5Server.log.length).toBeGreaterThan(0);
|
||||
expect(socks5Server.log.some(log => log.includes("CONNECT"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should connect through SOCKS5 proxy without authentication (HTTPS)", async () => {
|
||||
const response = await fetch(`https://localhost:${httpsServer.port}/test`, {
|
||||
proxy: socks5Server.url,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
expect(text).toContain("HTTPS response");
|
||||
expect(socks5Server.log.some(log => log.includes("CONNECT"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle POST requests through SOCKS5 proxy", async () => {
|
||||
const testData = "test body data";
|
||||
const response = await fetch(`http://localhost:${httpServer.port}/post`, {
|
||||
method: "POST",
|
||||
body: testData,
|
||||
proxy: socks5Server.url,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOCKS5 Proxy - Authentication", () => {
|
||||
test("should authenticate with username and password", async () => {
|
||||
const response = await fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: socks5AuthServer.urlWithAuth,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(socks5AuthServer.log.some(log => log.includes("Auth: username=testuser"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should fail with wrong password", async () => {
|
||||
const wrongAuthUrl = `socks5://testuser:wrongpass@localhost:${socks5AuthServer.port}`;
|
||||
|
||||
await expect(
|
||||
fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: wrongAuthUrl,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail when authentication is required but not provided", async () => {
|
||||
// Try without auth when auth is required
|
||||
const noAuthUrl = `socks5://localhost:${socks5AuthServer.port}`;
|
||||
|
||||
await expect(
|
||||
fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: noAuthUrl,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOCKS5 Proxy - DNS Resolution", () => {
|
||||
test("socks5:// should allow local DNS resolution", async () => {
|
||||
// With socks5://, Bun may resolve DNS locally
|
||||
const response = await fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: socks5Server.url,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("socks5h:// should force remote DNS resolution", async () => {
|
||||
// With socks5h://, DNS resolution happens on proxy server
|
||||
const socks5hUrl = socks5Server.url.replace("socks5://", "socks5h://");
|
||||
|
||||
const response = await fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: socks5hUrl,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// With socks5h://, we should see domain name in CONNECT, not IP
|
||||
expect(socks5Server.log.some(log => log.includes("localhost"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOCKS5 Proxy - Error Handling", () => {
|
||||
test("should handle connection refused", async () => {
|
||||
const failServer = await createSOCKS5ProxyServer({
|
||||
failConnection: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: failServer.url,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
failServer.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle invalid SOCKS5 proxy URL", async () => {
|
||||
await expect(
|
||||
fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: "socks5://invalid-host-that-does-not-exist:1080",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should handle proxy connection timeout", async () => {
|
||||
// Create a server that accepts connections but never responds
|
||||
const timeoutServer = net.createServer(socket => {
|
||||
// Accept but never send greeting response
|
||||
socket.on("data", () => {});
|
||||
});
|
||||
|
||||
timeoutServer.listen(0);
|
||||
await once(timeoutServer, "listening");
|
||||
const address = timeoutServer.address();
|
||||
const port = typeof address === "object" && address !== null ? address.port : 0;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: `socks5://localhost:${port}`,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
timeoutServer.close();
|
||||
}
|
||||
}, 10000); // Longer timeout for this test
|
||||
});
|
||||
|
||||
describe("SOCKS5 Proxy - Protocol Compliance", () => {
|
||||
test("should send correct SOCKS5 version", async () => {
|
||||
const testServer = await createSOCKS5ProxyServer({ logRequests: true });
|
||||
|
||||
try {
|
||||
await fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: testServer.url,
|
||||
});
|
||||
|
||||
// Check that greeting was received (version check happens in server)
|
||||
expect(testServer.log.some(log => log.includes("Greeting"))).toBe(true);
|
||||
} finally {
|
||||
testServer.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle all address types (IPv4, domain)", async () => {
|
||||
const testServer = await createSOCKS5ProxyServer({ logRequests: true });
|
||||
|
||||
try {
|
||||
// Test with domain name (localhost)
|
||||
await fetch(`http://localhost:${httpServer.port}/test`, {
|
||||
proxy: testServer.url,
|
||||
});
|
||||
|
||||
expect(testServer.log.some(log => log.includes("CONNECT"))).toBe(true);
|
||||
|
||||
// Test with IPv4 (127.0.0.1)
|
||||
await fetch(`http://127.0.0.1:${httpServer.port}/test`, {
|
||||
proxy: testServer.url,
|
||||
});
|
||||
|
||||
expect(testServer.log.filter(log => log.includes("CONNECT")).length).toBe(2);
|
||||
} finally {
|
||||
testServer.server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOCKS5 Proxy - Environment Variables", () => {
|
||||
test("should use SOCKS5_PROXY environment variable", async () => {
|
||||
const env = {
|
||||
...bunEnv,
|
||||
SOCKS5_PROXY: socks5Server.url,
|
||||
};
|
||||
|
||||
using server = Bun.spawn({
|
||||
cmd: [
|
||||
bunExe(),
|
||||
"-e",
|
||||
`
|
||||
const response = await fetch("http://localhost:${httpServer.port}/test");
|
||||
console.log(response.status);
|
||||
`,
|
||||
],
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([server.stdout.text(), server.stderr.text(), server.exited]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.trim()).toBe("200");
|
||||
});
|
||||
|
||||
test("should use HTTP_PROXY with socks5:// protocol", async () => {
|
||||
const env = {
|
||||
...bunEnv,
|
||||
HTTP_PROXY: socks5Server.url,
|
||||
};
|
||||
|
||||
using server = Bun.spawn({
|
||||
cmd: [
|
||||
bunExe(),
|
||||
"-e",
|
||||
`
|
||||
const response = await fetch("http://localhost:${httpServer.port}/test");
|
||||
console.log(response.status);
|
||||
`,
|
||||
],
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([server.stdout.text(), server.stderr.text(), server.exited]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.trim()).toBe("200");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user