diff --git a/README.md b/README.md index 02962645..415f2deb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ See [HTTPClient](http://www.rubydoc.info/gems/httpclient/frames) for documentati ## Features * methods like GET/HEAD/POST/* via HTTP/1.1. -* HTTPS(SSL), Cookies, proxy, authentication(Digest, NTLM, Basic), etc. +* HTTPS(SSL), Cookies, (HTTP, socks[45])proxy, authentication(Digest, NTLM, Basic), etc. * asynchronous HTTP request, streaming HTTP request. * debug mode CLI. * by contrast with net/http in standard distribution; @@ -24,6 +24,7 @@ See [HTTPClient](http://www.rubydoc.info/gems/httpclient/frames) for documentati * extensible with filter interface * you don't have to care HTTP/1.1 persistent connection (httpclient cares instead of you) + * socks proxy * Not supported now * Cache * Rather advanced HTTP/1.1 usage such as Range, deflate, etc. diff --git a/lib/httpclient.rb b/lib/httpclient.rb index e1f18647..8afca57c 100644 --- a/lib/httpclient.rb +++ b/lib/httpclient.rb @@ -44,9 +44,12 @@ # clnt = HTTPClient.new # # 2. Accessing resources through HTTP proxy. You can use environment -# variable 'http_proxy' or 'HTTP_PROXY' instead. +# variable 'http_proxy' or 'HTTP_PROXY' or 'SOCKS_PROXY' instead. # # clnt = HTTPClient.new('http://myproxy:8080') +# clnt = HTTPClient.new('socks4://myproxy:1080') +# clnt = HTTPClient.new('socks5://myproxy:1080') +# clnt = HTTPClient.new('socks5://username:password@myproxy:1080') # # === How to retrieve web resources # @@ -503,8 +506,9 @@ def proxy=(proxy) @proxy_auth.reset_challenge else @proxy = urify(proxy) - if @proxy.scheme == nil or @proxy.scheme.downcase != 'http' or - @proxy.host == nil or @proxy.port == nil + if @proxy.scheme == nil or + (@proxy.scheme.downcase != 'http' and !socks?(@proxy)) or + @proxy.host == nil or @proxy.port == nil raise ArgumentError.new("unsupported proxy #{proxy}") end @proxy_auth.reset_challenge @@ -1073,8 +1077,10 @@ def load_environment # HTTP_* is used for HTTP header information. Unlike open-uri, we # simply ignore http_proxy in CGI env and use cgi_http_proxy instead. self.proxy = getenv('cgi_http_proxy') - else + elsif ENV.key?('http_proxy') self.proxy = getenv('http_proxy') + else + self.proxy = getenv('socks_proxy') end # no_proxy self.no_proxy = getenv('no_proxy') diff --git a/lib/httpclient/session.rb b/lib/httpclient/session.rb index 67e2c3ba..40833849 100644 --- a/lib/httpclient/session.rb +++ b/lib/httpclient/session.rb @@ -21,6 +21,7 @@ require 'httpclient/timeout' # TODO: remove this once we drop 1.8 support require 'httpclient/ssl_config' require 'httpclient/http' +require 'httpclient/socks' if defined? JRUBY_VERSION require 'httpclient/jruby_ssl_socket' else @@ -92,6 +93,8 @@ def inspect # :nodoc: # Manages sessions for a HTTPClient instance. class SessionManager + include Util + # Name of this client. Used for 'User-Agent' header in HTTP request. attr_accessor :agent_name # Owner of this client. Used for 'From' header in HTTP request. @@ -132,6 +135,8 @@ class SessionManager def initialize(client) @client = client @proxy = client.proxy + @socks_user = nil + @socks_password = nil @agent_name = nil @from = nil @@ -167,6 +172,10 @@ def proxy=(proxy) @proxy = nil else @proxy = Site.new(proxy) + if socks?(proxy) + @socks_user = proxy.user + @socks_password = proxy.password + end end end @@ -218,6 +227,8 @@ def open(uri, via_proxy = false) site = Site.new(uri) sess = Session.new(@client, site, @agent_name, @from) sess.proxy = via_proxy ? @proxy : nil + sess.socks_user = @socks_user + sess.socks_password = @socks_password sess.socket_sync = @socket_sync sess.tcp_keepalive = @tcp_keepalive sess.requested_version = @protocol_version if @protocol_version @@ -448,6 +459,9 @@ class Session # Device for dumping log for debugging attr_accessor :debug_dev + attr_accessor :socks_user + attr_accessor :socks_password + attr_accessor :connect_timeout attr_accessor :connect_retry attr_accessor :send_timeout @@ -469,6 +483,8 @@ def initialize(client, dest, agent_name, from) @client = client @dest = dest @proxy = nil + @socks_user = nil + @socks_password = nil @socket_sync = true @tcp_keepalive = false @requested_version = nil @@ -627,6 +643,32 @@ def create_socket(host, port) socket end + def via_socks_proxy? + @proxy && socks?(@proxy) + end + + def via_http_proxy? + @proxy && !socks?(@proxy) + end + + def create_socks_socket(proxy, dest) + if socks4?(proxy) + options = {} + options = { user: @socks_user } if @socks_user + socks_socket = SOCKS4Socket.new(proxy.host, proxy.port, options) + elsif socks5?(proxy) + options = {} + options = { user: @socks_user } if @socks_user + options[:password] = @socks_password if @socks_password + socks_socket = SOCKS5Socket.new(proxy.host, proxy.port, options) + else + raise "invalid proxy url #{proxy}" + end + dest_site = Site.new(dest) + opened_socket = socks_socket.open(dest_site.host, dest_site.port, {}) + opened_socket + end + def create_loopback_socket(host, port, str) @debug_dev << "! CONNECT TO #{host}:#{port}\n" if @debug_dev socket = LoopBackSocket.new(host, port, str) @@ -751,6 +793,8 @@ def connect elsif https?(@dest) @socket = SSLSocket.create_socket(self) @ssl_peer_cert = @socket.peer_cert + elsif socks?(@proxy) + @socket = create_socks_socket(@proxy, @dest) else @socket = create_socket(site.host, site.port) end diff --git a/lib/httpclient/socks.rb b/lib/httpclient/socks.rb new file mode 100644 index 00000000..a2ad47fe --- /dev/null +++ b/lib/httpclient/socks.rb @@ -0,0 +1,213 @@ +# Copyright (c) 2008 Jamis Buck +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +require 'socket' +require 'resolv' +require 'ipaddr' + +# +# This implementation was borrowed from Net::SSH::Proxy +# +class HTTPClient + # An Standard SOCKS error. + class SOCKSError < SocketError; end + + # Used for reporting proxy connection errors. + class SOCKSConnectError < SOCKSError; end + + # Used when the server doesn't recognize the user's credentials. + class SOCKSUnauthorizedError < SOCKSError; end + + # An implementation of a SOCKS4 proxy. + class SOCKS4Socket + # The SOCKS protocol version used by this class + VERSION = 4 + + # The packet type for connection requests + CONNECT = 1 + + # The status code for a successful connection + GRANTED = 90 + + # The proxy's host name or IP address, as given to the constructor. + attr_reader :proxy_host + + # The proxy's port number. + attr_reader :proxy_port + + # The additional options that were given to the proxy's constructor. + attr_reader :options + + # Create a new proxy connection to the given proxy host and port. + # Optionally, a :user key may be given to identify the username + # with which to authenticate. + def initialize(proxy_host, proxy_port = 1080, options = {}) + @proxy_host = proxy_host + @proxy_port = proxy_port + @options = options + end + + # Return a new socket connected to the given host and port via the + # proxy that was requested when the socket factory was instantiated. + def open(host, port, connection_options) + socket = Socket.tcp(proxy_host, proxy_port, nil, nil, + connect_timeout: connection_options[:timeout]) + ip_addr = IPAddr.new(Resolv.getaddress(host)) + + packet = [ + VERSION, CONNECT, port.to_i, + ip_addr.to_i, options[:user] + ].pack('CCnNZ*') + socket.send packet, 0 + + _version, status, _port, _ip = socket.recv(8).unpack('CCnN') + if status != GRANTED + socket.close + raise SOCKSConnectError, "error connecting to socks proxy (#{status})" + end + + socket + end + end + + # An implementation of a SOCKS5 proxy. + class SOCKS5Socket + # The SOCKS protocol version used by this class + VERSION = 5 + + # The SOCKS authentication type for requests without authentication + METHOD_NO_AUTH = 0 + + # The SOCKS authentication type for requests via username/password + METHOD_PASSWD = 2 + + # The SOCKS authentication type for when there are no supported + # authentication methods. + METHOD_NONE = 0xFF + + # The SOCKS packet type for requesting a proxy connection. + CMD_CONNECT = 1 + + # The SOCKS address type for connections via IP address. + ATYP_IPV4 = 1 + + # The SOCKS address type for connections via domain name. + ATYP_DOMAIN = 3 + + # The SOCKS response code for a successful operation. + SUCCESS = 0 + + # The proxy's host name or IP address + attr_reader :proxy_host + + # The proxy's port number + attr_reader :proxy_port + + # The map of options given at initialization + attr_reader :options + + # Create a new proxy connection to the given proxy host and port. + # Optionally, :user and :password options may be given to + # identify the username and password with which to authenticate. + def initialize(proxy_host, proxy_port = 1080, options = {}) + @proxy_host = proxy_host + @proxy_port = proxy_port + @options = options + end + + # Return a new socket connected to the given host and port via the + # proxy that was requested when the socket factory was instantiated. + def open(host, port, connection_options) + socket = Socket.tcp(proxy_host, proxy_port, nil, nil, + connect_timeout: connection_options[:timeout]) + + methods = [METHOD_NO_AUTH] + methods << METHOD_PASSWD if options[:user] + + packet = [VERSION, methods.size, *methods].pack('C*') + socket.send packet, 0 + + version, method = socket.recv(2).unpack('CC') + if version != VERSION + socket.close + raise SOCKSError, "invalid SOCKS version (#{version})" + end + + if method == METHOD_NONE + socket.close + raise SOCKSError, 'no supported authorization methods' + end + + negotiate_password(socket) if method == METHOD_PASSWD + + packet = [VERSION, CMD_CONNECT, 0].pack('C*') + + if host =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/ + packet << [ATYP_IPV4, $1.to_i, $2.to_i, $3.to_i, $4.to_i].pack('C*') + else + packet << [ATYP_DOMAIN, host.length, host].pack('CCA*') + end + + packet << [port].pack('n') + socket.send packet, 0 + + _version, reply, = socket.recv(2).unpack('C*') + socket.recv(1) + address_type = socket.recv(1).getbyte(0) + case address_type + when 1 + socket.recv(4) # get four bytes for IPv4 address + when 3 + len = socket.recv(1).getbyte(0) + _hostname = socket.recv(len) + when 4 + _ipv6addr _hostname = socket.recv(16) + else + socket.close + raise SOCKSConnectError, 'Illegal response type' + end + _portnum = socket.recv(2) + + unless reply == SUCCESS + socket.close + raise SOCKSConnectError, reply.to_s + end + + socket + end + + private + + # Simple username/password negotiation with the SOCKS5 server. + def negotiate_password(socket) + packet = [ + 0x01, options[:user].length, options[:user], + options[:password].length, options[:password] + ].pack('CCA*CA*') + socket.send packet, 0 + + _version, status = socket.recv(2).unpack('CC') + + return if status == SUCCESS + socket.close + raise SOCKSUnauthorizedError, 'could not authorize user' + end + end +end diff --git a/lib/httpclient/ssl_socket.rb b/lib/httpclient/ssl_socket.rb index 14801428..ffa7e4c9 100644 --- a/lib/httpclient/ssl_socket.rb +++ b/lib/httpclient/ssl_socket.rb @@ -18,9 +18,14 @@ def self.create_socket(session) :debug_dev => session.debug_dev } site = session.proxy || session.dest - socket = session.create_socket(site.host, site.port) + if session.via_socks_proxy? + socket = session.create_socks_socket(session.proxy, session.dest) + else + socket = session.create_socket(site.host, site.port) + end + begin - if session.proxy + if session.via_http_proxy? session.connect_ssl_proxy(socket, Util.urify(session.dest.to_s)) end new(socket, session.dest, session.ssl_config, opts) diff --git a/lib/httpclient/util.rb b/lib/httpclient/util.rb index 6ff38016..64d767cd 100644 --- a/lib/httpclient/util.rb +++ b/lib/httpclient/util.rb @@ -216,6 +216,19 @@ def https?(uri) def http?(uri) uri.scheme && uri.scheme.downcase == 'http' end + + def socks?(uri) + uri && (socks4?(uri) || socks5?(uri)) + end + + def socks4?(uri) + uri && uri.scheme && uri.scheme.downcase == 'socks4' + end + + def socks5?(uri) + uri && uri.scheme && uri.scheme.downcase == 'socks5' + end + end diff --git a/sample/socks.rb b/sample/socks.rb new file mode 100644 index 00000000..5ebbd467 --- /dev/null +++ b/sample/socks.rb @@ -0,0 +1,16 @@ +require 'httpclient' + +# ssh -fN -D 9999 remote_server +clnt = HTTPClient.new('socks4://localhost:9999') + +#clnt = HTTPClient.new('socks5://localhost:9999') +#clnt = HTTPClient.new('socks5://username:password@localhost:9999') + +#ENV['SOCKS_PROXY'] = 'socks5://localhost:9999' +#clnt = HTTPClient.new + +target = 'http://www.example.com/' +puts clnt.get(target).content + +target = 'https://www.google.co.jp/' +puts clnt.get(target).content diff --git a/test/helper.rb b/test/helper.rb index 26bc4f9b..501e55a0 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -10,12 +10,14 @@ require 'httpclient' require 'webrick' +require 'webrick/https' require 'webrick/httpproxy.rb' require 'logger' require 'stringio' require 'cgi' require 'webrick/httputils' +require File.expand_path('sockssvr', File.dirname(__FILE__)) module Helper Port = 17171 @@ -37,6 +39,22 @@ def proxyurl "http://localhost:#{proxyport}/" end + def socks4_proxyurl + "socks4://username@localhost:#{proxyport}/" + end + + def socks5_noauth_proxyurl + "socks5://localhost:#{proxyport}/" + end + + def socks5_auth_proxyurl + "socks5://admin:admin@localhost:#{proxyport}/" + end + + def socks5_invalid_user_proxyurl + "socks5://invaliduser:pass@localhost:#{proxyport}/" + end + def setup @logger = Logger.new(STDERR) @logger.level = Logger::Severity::FATAL @@ -92,6 +110,12 @@ def teardown_proxyserver #@proxyserver_thread.kill end + def setup_socksproxyserver + @proxyserver = SOCKSServer.new(0) + @proxyport = @proxyserver.port + @proxyserver.start + end + def start_server_thread(server) t = Thread.new { Thread.current.abort_on_exception = true diff --git a/test/sockssvr.rb b/test/sockssvr.rb new file mode 100644 index 00000000..f64fabf4 --- /dev/null +++ b/test/sockssvr.rb @@ -0,0 +1,374 @@ +require 'logger' +require 'socket' + +TEST_USERNAME = 'admin'.freeze +TEST_PASSWORD = 'admin'.freeze + +# +# Generic SOCKS[45] server implementation +# +class SOCKSServer + attr_reader :port + + # protocol version + SOCKS_VERSION_4 = 4 + SOCKS_VERSION_5 = 5 + + # auth method + SOCKS_NO_AUTH = 0 + SOCKS_USER_PASS_AUTH = 2 + + # auth result + SOCKS_AUTH_SUCCESS = 1 + SOCKS_AUTH_FAILURE = 2 + + # host type + SOCKS_HOSTTYPE_IPV4 = 1 + SOCKS_HOSTTYPE_DOMAIN = 3 + + # command + SOCKS_COMMAND_CONNECT = 1 + + # misc + SOCKS4_MAX_USERNAME = 255 + + class SOCKSProtocolError < StandardError; end + + def initialize(port = 1080) + @server = TCPServer.new(port) + addr = @server.addr + addr.shift + @port = addr[0] + @logger = Logger.new(STDERR) + @logger.level = Logger::WARN + @protocol_version = nil + end + + def start + @logger.info('main thread started!') + Thread.start do + loop do + begin + Thread.start(@server.accept) do |client_socket| + @logger.info("client from #{client_socket.peeraddr[2]}") + begin + handle_client(client_socket) + rescue SOCKSProtocolError => e + @logger.warn(e) + send_protocol_error_response(client_socket) + rescue RuntimeError => e1 + @logger.warn(e1) + rescue Errno::ECONNRESET => e2 + @logger.warn("SOCKSServer: Connection Reset by peer") + ensure + client_socket.close + end + end + rescue RuntimeError, IOError => e2 # @server.accept error + @logger.warn(e2) unless e2.instance_of?(IOError) + break + end + end + end + end + + def shutdown + @server.close unless @server.closed? + @logger.info('Socks server closed... ') + end + + private + + def handle_client(client_socket) + socks_version = parse_protocol_version(client_socket) + @protocol_version = socks_version + + case @protocol_version + when SOCKS_VERSION_4 # not socks4a, but socks4 + @logger.debug('entering socks4 protocol mode...') + session = create_socks4_session(client_socket) + client_socket.send([0x00, 0x5a].concat([0xFF] * 6).pack('C*'), 0) + forward_packet(session, client_socket) + when SOCKS_VERSION_5 + @logger.debug('entering socks5 protocol mode...') + auth_method = get_auth_method(client_socket) + client_socket.send([SOCKS_VERSION_5, auth_method].pack('C*'), 0) + unless auth_method == SOCKS_NO_AUTH + auth_result = authenticate(client_socket) + send_auth_response(auth_result, client_socket) + return unless auth_result == SOCKS_AUTH_SUCCESS + end + + session = create_socks5_session(client_socket) + forward_packet(session, client_socket) + end + end + + def get_username(socket) + username = nil + if @protocol_version == SOCKS_VERSION_4 + username = socket.recv(SOCKS4_MAX_USERNAME).unpack('Z*').join('') + elsif @protocol_version == SOCKS_VERSION_5 + username_length = socket.recv(1).unpack('C')[0] + username = socket.recv(username_length) + end + @logger.debug("got username -> #{username}") + username + end + + def get_password(socket) + if @protocol_version == SOCKS_VERSION_4 + raise SOCKSProtocolError, 'unsupported field -> password' + end + password_length = socket.recv(1).unpack('C')[0] + password = socket.recv(password_length) + @logger.debug("got password -> #{password}") + password + end + + def parse_protocol_version(socket) + socks_version = socket.recv(1).unpack('C')[0] + if socks_version != SOCKS_VERSION_4 && socks_version != SOCKS_VERSION_5 + raise "bad socks protocol version -> #{socks_version}" + end + socks_version + end + + def get_auth_method(socket) + number_of_methods = socket.recv(1).unpack('C')[0] + if number_of_methods < 1 + raise SOCKSProtocolError, + 'auth method must be > 0' + end + + @logger.debug("select auth method from #{number_of_methods} choice") + + choice_methods = [] + number_of_methods.times do + auth_method = socket.recv(1).unpack('C')[0] + # auth_method == 0 -> NO AUTH + # auth_method == 2 -> USERNAME:PASSWORD AUTH + if [SOCKS_NO_AUTH, SOCKS_USER_PASS_AUTH].include?(auth_method) + @logger.debug("auth method get success -> #{auth_method}") + choice_methods.push(auth_method) + end + end + + raise SOCKSProtocolError, 'unsupported auth method' if choice_methods.empty? + + case choice_methods.size + when 1 + @logger.debug("selected auth method -> #{choice_methods[0]}") + return choice_methods[0] + when 2 + @logger.debug('selected auth method -> 2') + return SOCKS_USER_PASS_AUTH + end + end + + def authenticate(socket) + version = socket.recv(1).unpack('C')[0] + if version != 1 + raise SOCKSProtocolError, + "invalid auth version -> #{version} must be 1" + end + + username = get_username(socket) + password = get_password(socket) + + if username == TEST_USERNAME && password == TEST_PASSWORD + @logger.debug('socks5 username:password auth -> success') + return SOCKS_AUTH_SUCCESS + else + @logger.debug('socks5 username:password auth -> fail') + return SOCKS_AUTH_FAILURE + end + end + + def send_auth_response(auth_result, socket) + case auth_result + when SOCKS_AUTH_SUCCESS + socket.send([SOCKS_VERSION_5, 0x00].pack('C*'), 0) + when SOCKS_AUTH_FAILURE + socket.send([SOCKS_VERSION_5, 0xFF].pack('C*'), 0) + end + end + + def create_socks4_session(socket) + command = get_command(socket) + port, raw_port = get_port(socket) + host, raw_host = get_ipv4_host(socket) + _username = get_username(socket) + session = { + address_type: SOCKS_HOSTTYPE_IPV4, + host: host, + port: port, + raw_host: raw_host, + raw_port: raw_port, + command: command + } + session + end + + def create_socks5_session(socket) + validate_protocol_version(socket) + + command = get_command(socket) + check_reserved_byte(socket) + session = get_host_and_port(socket) + session[:command] = command + session + end + + def validate_protocol_version(socket) + version = parse_protocol_version(socket) + unless @protocol_version == version + raise SOCKSProtocolError, + "protoversion mismatch #{@protocol_version} != #{version}" + end + version + end + + def get_command(socket) + @logger.debug('get connection type') + command = socket.recv(1).unpack('C')[0] + @logger.debug("command => #{command}") + command + end + + def check_reserved_byte(socket) + # reserved byte + reserved_byte = socket.recv(1).unpack('C')[0] + unless reserved_byte.zero? + raise SOCKSProtocolError, + "reserved byte must be zero, but #{reserved_byte}" + end + @logger.debug("reserved byte => #{reserved_byte}") + end + + def get_ipv4_host(socket) + raw_host = socket.recv(4).unpack('C*') + host = raw_host.join('.') + @logger.debug("host => #{host}") + if host.empty? + raise SOCKSProtocolError, + 'specified host is invalid!' + end + [host, raw_host] + end + + def get_domain(socket) + domain_length = socket.recv(1).unpack('C')[0] + if domain_length < 1 + raise SOCKSProtocolError, + 'domain length is too short!' + end + + host = socket.recv(domain_length) + raw_host = host.unpack('C*') + [host, raw_host] + end + + def get_port(socket) + raw_port = socket.recv(2).unpack('C*') + port = raw_port[0] << 8 | raw_port[1] + [port, raw_port] + end + + def get_host_and_port(socket) + address_type = socket.recv(1).unpack('C')[0] + @logger.debug("address_type #{address_type}") + + case address_type + when SOCKS_HOSTTYPE_IPV4 + @logger.debug('hostname type is IPv4') + host, raw_host = get_ipv4_host(socket) + port, raw_port = get_port(socket) + when SOCKS_HOSTTYPE_DOMAIN + @logger.debug('host type is domain name') + host, raw_host = get_domain(socket) + port, raw_port = get_port(socket) + end + + @logger.debug("host => #{host}") + @logger.debug("raw host => #{raw_host}") + @logger.debug("port => #{port}") + @logger.debug("raw port => #{raw_port}") + + session = { + address_type: address_type, + host: host, + port: port, + raw_host: raw_host, + raw_port: raw_port + } + session + end + + def forward_packet(session, client_socket) + command = session[:command] + raise 'CONNECT is only implemented' unless command == SOCKS_COMMAND_CONNECT + + client_addr = client_socket.peeraddr[2] + + host = session[:host] + port = session[:port] + + @logger.debug("client #{client_addr} connecting to #{host}:#{port} ...") + + dest_socket = TCPSocket.open(host, port) + if dest_socket + target_addr = dest_socket.peeraddr[2] + send_socks5_response(client_socket, 0, session) # success + + loop do # main io loop + readables, _, exceptions = IO.select([client_socket, dest_socket]) + raise exceptions[0] unless exceptions.empty? + + readables.each do |io| + if io == client_socket + client_msg = client_socket.recv(4096) + next if client_msg.length.zero? + @logger.debug("recvmsg from client => #{client_msg.length} bytes") + @logger.debug("send to dest #{target_addr} => #{client_msg}") + dest_socket.sendmsg(client_msg) + elsif io == dest_socket + dest_msg = dest_socket.recv(4096) + next if dest_msg.length.zero? + @logger.debug("recvmsg from dest => #{dest_msg.length} bytes") + @logger.debug("send to client #{client_addr} => #{dest_msg}") + client_socket.sendmsg(dest_msg) + end + end + end + else + @logger.error("connecting to #{host}:#{port} fail") + send_socks5_response(client_socket, 1, session) # error + end + end + + def send_protocol_error_response(client_socket) + if @protocol_version == SOCKS_VERSION_4 + client_socket.send([0x00, 0x5b].concat([0xff] * 6).pack('C*'), 0) + elsif @protocol_version == SOCKS_VERSION_5 + client_socket.send([SOCKS_VERSION_5, 0xff].pack('C*'), 0) + end + end + + def send_socks5_response(socket, code, session) + return unless @protocol_version == SOCKS_VERSION_5 + + host = session[:host] + address_type = session[:address_type] + raw_host = session[:raw_host] + raw_port = session[:raw_port] + + resp = [ + SOCKS_VERSION_5, code, 0x00 + ].push(address_type).push( + host.length + ).concat(raw_host).concat(raw_port) + @logger.debug("resp => #{resp.inspect}") + socket.send(resp.pack('C*'), 0) + end +end diff --git a/test/test_httpclient.rb b/test/test_httpclient.rb index c8e5330c..ba95f477 100644 --- a/test/test_httpclient.rb +++ b/test/test_httpclient.rb @@ -28,6 +28,32 @@ def test_initialize end end + def test_socks_initialize + setup_socksproxyserver + + [ + socks4_proxyurl, + socks5_noauth_proxyurl, + socks5_auth_proxyurl + ].each do |proxyurl| + escape_noproxy do + @client = HTTPClient.new(proxyurl) + assert_equal(urify(proxyurl), @client.proxy) + assert_equal(200, @client.head(serverurl).status) + end + end + end + + def test_socks5_autherror + setup_socksproxyserver + escape_noproxy do + @client = HTTPClient.new(socks5_invalid_user_proxyurl) + assert_raises(HTTPClient::SOCKSUnauthorizedError) do + @client.get(serverurl) + end + end + end + def test_agent_name @client = HTTPClient.new(nil, "agent_name_foo") str = "" @@ -241,7 +267,9 @@ def test_host_header def test_proxy_env setup_proxyserver escape_env do + # put HTTP_PROXY env ahead of SOCKS_PROXY ENV['http_proxy'] = "http://admin:admin@foo:1234" + ENV['socks_proxy'] = 'socks5://admin:admin@foo:1234' ENV['NO_PROXY'] = "foobar" client = HTTPClient.new assert_equal(urify("http://admin:admin@foo:1234"), client.proxy) @@ -249,6 +277,17 @@ def test_proxy_env end end + def test_socks_proxy_env + setup_socksproxyserver + escape_env do + ENV['socks_proxy'] = 'socks5://admin:admin@foo:1234' + ENV['NO_PROXY'] = "foobar" + client = HTTPClient.new + assert_equal(urify("socks5://admin:admin@foo:1234"), client.proxy) + assert_equal('foobar', client.no_proxy) + end + end + def test_proxy_env_cgi setup_proxyserver escape_env do @@ -405,6 +444,20 @@ def test_proxy_ssl end end + def test_socks_proxy_ssl + setup_socksproxyserver + setup_sslserver + escape_noproxy do + curdir = File.dirname(File.expand_path(__FILE__)) + @client.proxy = socks5_auth_proxyurl + @client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE + assert_equal( + 'hello', + @client.get("https://localhost:#{@serverport}/hello").content + ) + end + end + def test_loopback_response @client.test_loopback_response << 'message body 1' @client.test_loopback_response << 'message body 2' @@ -1957,6 +2010,38 @@ def setup_server @server_thread = start_server_thread(@server) end + def setup_sslserver + curdir = File.dirname(File.expand_path(__FILE__)) + servercert = OpenSSL::X509::Certificate.new(File.open(File.join(curdir, 'server.cert')) { |f| + f.read + }) + key = OpenSSL::PKey::RSA.new(File.open(File.join(curdir, 'server.key')) { |f| + f.read + }) + + @server = WEBrick::HTTPServer.new( + :BindAddress => "localhost", + :Logger => @logger, + :Port => 0, + :DocumentRoot => curdir, + :SSLEnable => true, + :SSLCACertificateFile => File.join(curdir, 'ca.cert'), + :SSLCertificate => servercert, + :SSLPrivateKey => key, + :SSLVerifyClient => ::OpenSSL::SSL::VERIFY_NONE, + :SSLCertName => nil + ) + @serverport = @server.config[:Port] + + [:hello].each do |sym| + @server.mount( + "/#{sym}", + WEBrick::HTTPServlet::ProcHandler.new(method("do_#{sym}").to_proc) + ) + end + @server_thread = start_server_thread(@server) + end + def add_query_string(req) if req.query_string '?' + req.query_string