Skip to content

Commit 26e9727

Browse files
committed
added SOCKS[45] proxy support
1 parent 788c1e1 commit 26e9727

File tree

11 files changed

+787
-8
lines changed

11 files changed

+787
-8
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ See [HTTPClient](http://www.rubydoc.info/gems/httpclient/frames) for documentati
1111
## Features
1212

1313
* methods like GET/HEAD/POST/* via HTTP/1.1.
14-
* HTTPS(SSL), Cookies, proxy, authentication(Digest, NTLM, Basic), etc.
14+
* HTTPS(SSL), Cookies, (HTTP, socks[45])proxy, authentication(Digest, NTLM, Basic), etc.
1515
* asynchronous HTTP request, streaming HTTP request.
1616
* debug mode CLI.
1717
* by contrast with net/http in standard distribution;
@@ -24,6 +24,7 @@ See [HTTPClient](http://www.rubydoc.info/gems/httpclient/frames) for documentati
2424
* extensible with filter interface
2525
* you don't have to care HTTP/1.1 persistent connection
2626
(httpclient cares instead of you)
27+
* socks proxy
2728
* Not supported now
2829
* Cache
2930
* Rather advanced HTTP/1.1 usage such as Range, deflate, etc.

lib/httpclient.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@
4444
# clnt = HTTPClient.new
4545
#
4646
# 2. Accessing resources through HTTP proxy. You can use environment
47-
# variable 'http_proxy' or 'HTTP_PROXY' instead.
47+
# variable 'http_proxy' or 'HTTP_PROXY' or 'SOCKS_PROXY' instead.
4848
#
4949
# clnt = HTTPClient.new('http://myproxy:8080')
50+
# clnt = HTTPClient.new('socks4://myproxy:1080')
51+
# clnt = HTTPClient.new('socks5://myproxy:1080')
52+
# clnt = HTTPClient.new('socks5://username:password@myproxy:1080')
5053
#
5154
# === How to retrieve web resources
5255
#
@@ -503,8 +506,9 @@ def proxy=(proxy)
503506
@proxy_auth.reset_challenge
504507
else
505508
@proxy = urify(proxy)
506-
if @proxy.scheme == nil or @proxy.scheme.downcase != 'http' or
507-
@proxy.host == nil or @proxy.port == nil
509+
if @proxy.scheme == nil or
510+
(@proxy.scheme.downcase != 'http' and !socks?(@proxy)) or
511+
@proxy.host == nil or @proxy.port == nil
508512
raise ArgumentError.new("unsupported proxy #{proxy}")
509513
end
510514
@proxy_auth.reset_challenge
@@ -1073,8 +1077,10 @@ def load_environment
10731077
# HTTP_* is used for HTTP header information. Unlike open-uri, we
10741078
# simply ignore http_proxy in CGI env and use cgi_http_proxy instead.
10751079
self.proxy = getenv('cgi_http_proxy')
1076-
else
1080+
elsif ENV.key?('http_proxy')
10771081
self.proxy = getenv('http_proxy')
1082+
else
1083+
self.proxy = getenv('socks_proxy')
10781084
end
10791085
# no_proxy
10801086
self.no_proxy = getenv('no_proxy')

lib/httpclient/session.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require 'httpclient/timeout' # TODO: remove this once we drop 1.8 support
2222
require 'httpclient/ssl_config'
2323
require 'httpclient/http'
24+
require 'httpclient/socks'
2425
if defined? JRUBY_VERSION
2526
require 'httpclient/jruby_ssl_socket'
2627
else
@@ -92,6 +93,8 @@ def inspect # :nodoc:
9293

9394
# Manages sessions for a HTTPClient instance.
9495
class SessionManager
96+
include Util
97+
9598
# Name of this client. Used for 'User-Agent' header in HTTP request.
9699
attr_accessor :agent_name
97100
# Owner of this client. Used for 'From' header in HTTP request.
@@ -132,6 +135,8 @@ class SessionManager
132135
def initialize(client)
133136
@client = client
134137
@proxy = client.proxy
138+
@socks_user = nil
139+
@socks_password = nil
135140

136141
@agent_name = nil
137142
@from = nil
@@ -167,6 +172,10 @@ def proxy=(proxy)
167172
@proxy = nil
168173
else
169174
@proxy = Site.new(proxy)
175+
if socks?(proxy)
176+
@socks_user = proxy.user
177+
@socks_password = proxy.password
178+
end
170179
end
171180
end
172181

@@ -218,6 +227,8 @@ def open(uri, via_proxy = false)
218227
site = Site.new(uri)
219228
sess = Session.new(@client, site, @agent_name, @from)
220229
sess.proxy = via_proxy ? @proxy : nil
230+
sess.socks_user = @socks_user
231+
sess.socks_password = @socks_password
221232
sess.socket_sync = @socket_sync
222233
sess.tcp_keepalive = @tcp_keepalive
223234
sess.requested_version = @protocol_version if @protocol_version
@@ -448,6 +459,9 @@ class Session
448459
# Device for dumping log for debugging
449460
attr_accessor :debug_dev
450461

462+
attr_accessor :socks_user
463+
attr_accessor :socks_password
464+
451465
attr_accessor :connect_timeout
452466
attr_accessor :connect_retry
453467
attr_accessor :send_timeout
@@ -469,6 +483,8 @@ def initialize(client, dest, agent_name, from)
469483
@client = client
470484
@dest = dest
471485
@proxy = nil
486+
@socks_user = nil
487+
@socks_password = nil
472488
@socket_sync = true
473489
@tcp_keepalive = false
474490
@requested_version = nil
@@ -627,6 +643,32 @@ def create_socket(host, port)
627643
socket
628644
end
629645

646+
def via_socks_proxy?
647+
@proxy && socks?(@proxy)
648+
end
649+
650+
def via_http_proxy?
651+
@proxy && !socks?(@proxy)
652+
end
653+
654+
def create_socks_socket(proxy, dest)
655+
if socks4?(proxy)
656+
options = {}
657+
options = { user: @socks_user } if @socks_user
658+
socks_socket = SOCKS4Socket.new(proxy.host, proxy.port, options)
659+
elsif socks5?(proxy)
660+
options = {}
661+
options = { user: @socks_user } if @socks_user
662+
options[:password] = @socks_password if @socks_password
663+
socks_socket = SOCKS5Socket.new(proxy.host, proxy.port, options)
664+
else
665+
raise "invalid proxy url #{proxy}"
666+
end
667+
dest_site = Site.new(dest)
668+
opened_socket = socks_socket.open(dest_site.host, dest_site.port, {})
669+
opened_socket
670+
end
671+
630672
def create_loopback_socket(host, port, str)
631673
@debug_dev << "! CONNECT TO #{host}:#{port}\n" if @debug_dev
632674
socket = LoopBackSocket.new(host, port, str)
@@ -751,6 +793,8 @@ def connect
751793
elsif https?(@dest)
752794
@socket = SSLSocket.create_socket(self)
753795
@ssl_peer_cert = @socket.peer_cert
796+
elsif socks?(@proxy)
797+
@socket = create_socks_socket(@proxy, @dest)
754798
else
755799
@socket = create_socket(site.host, site.port)
756800
end

lib/httpclient/socks.rb

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Copyright (c) 2008 Jamis Buck
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the 'Software'), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in all
11+
# copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
# SOFTWARE.
20+
21+
require 'socket'
22+
require 'resolv'
23+
require 'ipaddr'
24+
25+
#
26+
# This implementation was borrowed from Net::SSH::Proxy
27+
#
28+
class HTTPClient
29+
# An Standard SOCKS error.
30+
class SOCKSError < SocketError; end
31+
32+
# Used for reporting proxy connection errors.
33+
class SOCKSConnectError < SOCKSError; end
34+
35+
# Used when the server doesn't recognize the user's credentials.
36+
class SOCKSUnauthorizedError < SOCKSError; end
37+
38+
# An implementation of a SOCKS4 proxy.
39+
class SOCKS4Socket
40+
# The SOCKS protocol version used by this class
41+
VERSION = 4
42+
43+
# The packet type for connection requests
44+
CONNECT = 1
45+
46+
# The status code for a successful connection
47+
GRANTED = 90
48+
49+
# The proxy's host name or IP address, as given to the constructor.
50+
attr_reader :proxy_host
51+
52+
# The proxy's port number.
53+
attr_reader :proxy_port
54+
55+
# The additional options that were given to the proxy's constructor.
56+
attr_reader :options
57+
58+
# Create a new proxy connection to the given proxy host and port.
59+
# Optionally, a :user key may be given to identify the username
60+
# with which to authenticate.
61+
def initialize(proxy_host, proxy_port = 1080, options = {})
62+
@proxy_host = proxy_host
63+
@proxy_port = proxy_port
64+
@options = options
65+
end
66+
67+
# Return a new socket connected to the given host and port via the
68+
# proxy that was requested when the socket factory was instantiated.
69+
def open(host, port, connection_options)
70+
socket = Socket.tcp(proxy_host, proxy_port, nil, nil,
71+
connect_timeout: connection_options[:timeout])
72+
ip_addr = IPAddr.new(Resolv.getaddress(host))
73+
74+
packet = [
75+
VERSION, CONNECT, port.to_i,
76+
ip_addr.to_i, options[:user]
77+
].pack('CCnNZ*')
78+
socket.send packet, 0
79+
80+
_version, status, _port, _ip = socket.recv(8).unpack('CCnN')
81+
if status != GRANTED
82+
socket.close
83+
raise SOCKSConnectError, "error connecting to socks proxy (#{status})"
84+
end
85+
86+
socket
87+
end
88+
end
89+
90+
# An implementation of a SOCKS5 proxy.
91+
class SOCKS5Socket
92+
# The SOCKS protocol version used by this class
93+
VERSION = 5
94+
95+
# The SOCKS authentication type for requests without authentication
96+
METHOD_NO_AUTH = 0
97+
98+
# The SOCKS authentication type for requests via username/password
99+
METHOD_PASSWD = 2
100+
101+
# The SOCKS authentication type for when there are no supported
102+
# authentication methods.
103+
METHOD_NONE = 0xFF
104+
105+
# The SOCKS packet type for requesting a proxy connection.
106+
CMD_CONNECT = 1
107+
108+
# The SOCKS address type for connections via IP address.
109+
ATYP_IPV4 = 1
110+
111+
# The SOCKS address type for connections via domain name.
112+
ATYP_DOMAIN = 3
113+
114+
# The SOCKS response code for a successful operation.
115+
SUCCESS = 0
116+
117+
# The proxy's host name or IP address
118+
attr_reader :proxy_host
119+
120+
# The proxy's port number
121+
attr_reader :proxy_port
122+
123+
# The map of options given at initialization
124+
attr_reader :options
125+
126+
# Create a new proxy connection to the given proxy host and port.
127+
# Optionally, :user and :password options may be given to
128+
# identify the username and password with which to authenticate.
129+
def initialize(proxy_host, proxy_port = 1080, options = {})
130+
@proxy_host = proxy_host
131+
@proxy_port = proxy_port
132+
@options = options
133+
end
134+
135+
# Return a new socket connected to the given host and port via the
136+
# proxy that was requested when the socket factory was instantiated.
137+
def open(host, port, connection_options)
138+
socket = Socket.tcp(proxy_host, proxy_port, nil, nil,
139+
connect_timeout: connection_options[:timeout])
140+
141+
methods = [METHOD_NO_AUTH]
142+
methods << METHOD_PASSWD if options[:user]
143+
144+
packet = [VERSION, methods.size, *methods].pack('C*')
145+
socket.send packet, 0
146+
147+
version, method = socket.recv(2).unpack('CC')
148+
if version != VERSION
149+
socket.close
150+
raise SOCKSError, "invalid SOCKS version (#{version})"
151+
end
152+
153+
if method == METHOD_NONE
154+
socket.close
155+
raise SOCKSError, 'no supported authorization methods'
156+
end
157+
158+
negotiate_password(socket) if method == METHOD_PASSWD
159+
160+
packet = [VERSION, CMD_CONNECT, 0].pack('C*')
161+
162+
if host =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
163+
packet << [ATYP_IPV4, $1.to_i, $2.to_i, $3.to_i, $4.to_i].pack('C*')
164+
else
165+
packet << [ATYP_DOMAIN, host.length, host].pack('CCA*')
166+
end
167+
168+
packet << [port].pack('n')
169+
socket.send packet, 0
170+
171+
_version, reply, = socket.recv(2).unpack('C*')
172+
socket.recv(1)
173+
address_type = socket.recv(1).getbyte(0)
174+
case address_type
175+
when 1
176+
socket.recv(4) # get four bytes for IPv4 address
177+
when 3
178+
len = socket.recv(1).getbyte(0)
179+
_hostname = socket.recv(len)
180+
when 4
181+
_ipv6addr _hostname = socket.recv(16)
182+
else
183+
socket.close
184+
raise SOCKSConnectError, 'Illegal response type'
185+
end
186+
_portnum = socket.recv(2)
187+
188+
unless reply == SUCCESS
189+
socket.close
190+
raise SOCKSConnectError, reply.to_s
191+
end
192+
193+
socket
194+
end
195+
196+
private
197+
198+
# Simple username/password negotiation with the SOCKS5 server.
199+
def negotiate_password(socket)
200+
packet = [
201+
0x01, options[:user].length, options[:user],
202+
options[:password].length, options[:password]
203+
].pack('CCA*CA*')
204+
socket.send packet, 0
205+
206+
_version, status = socket.recv(2).unpack('CC')
207+
208+
return if status == SUCCESS
209+
socket.close
210+
raise SOCKSUnauthorizedError, 'could not authorize user'
211+
end
212+
end
213+
end

lib/httpclient/ssl_socket.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ def self.create_socket(session)
1818
:debug_dev => session.debug_dev
1919
}
2020
site = session.proxy || session.dest
21-
socket = session.create_socket(site.host, site.port)
21+
if session.via_socks_proxy?
22+
socket = session.create_socks_socket(session.proxy, session.dest)
23+
else
24+
socket = session.create_socket(site.host, site.port)
25+
end
26+
2227
begin
23-
if session.proxy
28+
if session.via_http_proxy?
2429
session.connect_ssl_proxy(socket, Util.urify(session.dest.to_s))
2530
end
2631
new(socket, session.dest, session.ssl_config, opts)

0 commit comments

Comments
 (0)