excon 1.3.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/excon.gemspec CHANGED
@@ -21,15 +21,12 @@ Gem::Specification.new do |s|
21
21
  'README.md',
22
22
  'excon.gemspec'
23
23
  ]
24
- s.required_ruby_version = '>= 3.1.0'
24
+ s.required_ruby_version = '>= 3.3.0'
25
25
 
26
26
  s.add_dependency 'logger'
27
27
 
28
28
  s.add_development_dependency('activesupport')
29
29
  s.add_development_dependency('delorean')
30
- s.add_development_dependency('eventmachine', '>= 1.0.4')
31
- s.add_development_dependency('json', '>= 1.8.5')
32
- s.add_development_dependency('open4')
33
30
  s.add_development_dependency('puma')
34
31
  s.add_development_dependency('rake')
35
32
  s.add_development_dependency('rdoc')
@@ -60,6 +60,8 @@ module Excon
60
60
  # @option params [Fixnum] :retry_interval Set how long to wait between retries. (Default 0)
61
61
  # @option params [Class] :instrumentor Responds to #instrument as in ActiveSupport::Notifications
62
62
  # @option params [String] :instrumentor_name Name prefix for #instrument events. Defaults to 'excon'
63
+ # @option params [Resolv, nil] :resolv_resolver A ready to use +Resolv+ resolver instance.
64
+ # @option params [Class] :resolver_factory Const of the resolver factory. Defaults to 'Excon::ResolverFactory'
63
65
  def initialize(params = {})
64
66
  @pid = Process.pid
65
67
  @data = Excon.defaults.dup
@@ -100,6 +102,10 @@ module Excon
100
102
  @socket_key = "#{@data[:scheme]}://#{@data[:socket]}"
101
103
  end
102
104
  else
105
+ if @data.key?(:host) && @data[:host].nil?
106
+ raise Excon::Error::InvalidParameter, "host is required for non-Unix connections; " \
107
+ "the URL may be malformed (e.g. missing \"//\" after the scheme)"
108
+ end
103
109
  @socket_key = "#{@data[:scheme]}://#{@data[:host]}#{port_string(@data)}"
104
110
  end
105
111
  reset
@@ -236,7 +242,11 @@ module Excon
236
242
  datum[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + datum[:timeout]
237
243
  end
238
244
 
239
- datum[:headers] = @data[:headers].merge(datum[:headers] || {})
245
+ # header names are case-insensitive, so request headers replace defaults regardless of casing
246
+ request_headers = datum[:headers] || {}
247
+ datum[:headers] = @data[:headers].reject do |key, _|
248
+ request_headers.keys.any? { |k| k.to_s.casecmp?(key.to_s) }
249
+ end.merge(request_headers)
240
250
 
241
251
  validate_params(:request, params, datum[:middlewares])
242
252
  # If the user passed in new middleware, we want to validate that the original connection parameters
@@ -254,6 +264,10 @@ module Excon
254
264
  if datum[:scheme] == UNIX
255
265
  datum[:headers][host_key] ||= ''
256
266
  else
267
+ if datum[:host].nil?
268
+ raise Excon::Error::InvalidParameter, "host is required for non-Unix connections; " \
269
+ "the URL may be malformed (e.g. missing \"//\" after the scheme)"
270
+ end
257
271
  datum[:headers][host_key] ||= datum[:host] + port_string(datum)
258
272
  end
259
273
 
@@ -483,6 +497,13 @@ module Excon
483
497
  unix_proxy = datum[:proxy] ? datum[:proxy][:scheme] == UNIX : false
484
498
  sockets[@socket_key] ||= if datum[:scheme] == UNIX || unix_proxy
485
499
  Excon::UnixSocket.new(datum)
500
+ elsif datum[:socks5_proxy]
501
+ # SOCKS5 proxy - use appropriate socket based on target scheme
502
+ if datum[:ssl_uri_schemes].include?(datum[:scheme])
503
+ Excon::SOCKS5SSLSocket.new(datum)
504
+ else
505
+ Excon::SOCKS5Socket.new(datum)
506
+ end
486
507
  elsif datum[:ssl_uri_schemes].include?(datum[:scheme])
487
508
  Excon::SSLSocket.new(datum)
488
509
  else
@@ -491,22 +512,21 @@ module Excon
491
512
  end
492
513
 
493
514
  def sockets
494
- @_excon_sockets ||= {}
495
- @_excon_sockets.compare_by_identity
496
-
497
515
  if @pid != Process.pid
498
- @_excon_sockets.clear # GC will take care of closing sockets
516
+ @_excon_sockets&.clear # GC will take care of closing sockets
499
517
  @pid = Process.pid
500
518
  end
501
519
 
502
520
  if @data[:thread_safe_sockets]
521
+ @_excon_sockets ||= ObjectSpace::WeakKeyMap.new
522
+
503
523
  # In a multi-threaded world, if the same connection is used by multiple
504
524
  # threads at the same time to connect to the same destination, they may
505
525
  # stomp on each other's sockets. This ensures every thread gets their
506
526
  # own socket cache, within the context of a single connection.
507
527
  @_excon_sockets[Thread.current] ||= {}
508
528
  else
509
- @_excon_sockets
529
+ @_excon_sockets ||= {}.compare_by_identity
510
530
  end
511
531
  end
512
532
 
@@ -60,6 +60,7 @@ module Excon
60
60
  read_timeout
61
61
  request_block
62
62
  resolv_resolver
63
+ resolver_factory
63
64
  response_block
64
65
  stubs
65
66
  timeout
@@ -97,6 +98,7 @@ module Excon
97
98
  proxy
98
99
  scheme
99
100
  socket
101
+ socks5_proxy
100
102
  ssl_ca_file
101
103
  ssl_ca_path
102
104
  ssl_cert_store
@@ -170,6 +172,7 @@ module Excon
170
172
  persistent: false,
171
173
  read_timeout: 60,
172
174
  resolv_resolver: nil,
175
+ resolver_factory: Excon::ResolverFactory,
173
176
  retry_errors: DEFAULT_RETRY_ERRORS,
174
177
  retry_limit: DEFAULT_RETRY_LIMIT,
175
178
  ssl_verify_peer: true,
data/lib/excon/error.rb CHANGED
@@ -48,6 +48,7 @@ or:
48
48
 
49
49
  class InvalidHeaderKey < Error; end
50
50
  class InvalidHeaderValue < Error; end
51
+ class InvalidParameter < Error; end
51
52
  class Timeout < Error; end
52
53
  class ResponseParse < Error; end
53
54
 
@@ -2,6 +2,19 @@
2
2
  module Excon
3
3
  module Middleware
4
4
  class RedirectFollower < Excon::Middleware::Base
5
+ REDACTION_HEADERS = %w[
6
+ Authorization
7
+ Cookie
8
+ Cookie2
9
+ Host
10
+ Proxy-Connection
11
+ Proxy-Authorization
12
+ X-API-Key
13
+ X-Auth-Token
14
+ X-CSRF-Token
15
+ X-Session-ID
16
+ ]
17
+
5
18
  def self.valid_parameter_keys
6
19
  [
7
20
  :redirects_remaining,
@@ -54,10 +67,9 @@ module Excon
54
67
  params[:headers].delete('Content-Length')
55
68
  end
56
69
  params[:headers] = datum[:headers].dup
57
- params[:headers].delete('Authorization')
58
- params[:headers].delete('Proxy-Connection')
59
- params[:headers].delete('Proxy-Authorization')
60
- params[:headers].delete('Host')
70
+ REDACTION_HEADERS.each do |header|
71
+ params[:headers].delete(header)
72
+ end
61
73
  params.merge!(
62
74
  :scheme => uri.scheme || datum[:scheme],
63
75
  :host => uri.host || datum[:host],
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Excon
3
+ # This factory produces new +resolv+ gem resolver instances. Users who wants
4
+ # to configure a custom resolver (varying settings for varying resolvers) can
5
+ # provide a custom resolver factory class and configure it globally on the
6
+ # Excon defaults:
7
+ #
8
+ # Excon.defaults[:resolver_factory] = MyCustomResolverFactory
9
+ #
10
+ # Then you just need to provide a static method called +.create_resolver+
11
+ # which returns a new +Resolv+ instance. This allows the customization.
12
+ class ResolverFactory
13
+ # @return [Resolv] the new resolver instance
14
+ def self.create_resolver
15
+ Resolv.new
16
+ end
17
+ end
18
+ end
data/lib/excon/socket.rb CHANGED
@@ -52,7 +52,8 @@ module Excon
52
52
  @data = data
53
53
  @nonblock = data[:nonblock]
54
54
  @port ||= @data[:port] || 80
55
- @read_buffer = String.new
55
+ @read_buffer = String.new(encoding: Encoding::BINARY)
56
+ @read_buffer_scratch = String.new(encoding: Encoding::BINARY)
56
57
  @read_offset = 0
57
58
  @eof = false
58
59
  @backend_eof = false
@@ -132,7 +133,7 @@ module Excon
132
133
  family = @data[:proxy][:family]
133
134
  end
134
135
 
135
- resolver = @data[:resolv_resolver] || Resolv.new
136
+ resolver = @data[:resolv_resolver] || @data[:resolver_factory].create_resolver
136
137
 
137
138
  # Deprecated
138
139
  if @data[:dns_timeouts]
@@ -239,7 +240,7 @@ module Excon
239
240
  # Avoid allocating a new buffer string when the read buffer is empty
240
241
  @read_buffer = @socket.read_nonblock(max_length, @read_buffer)
241
242
  else
242
- @read_buffer << @socket.read_nonblock(max_length - readable_bytes)
243
+ @read_buffer << @socket.read_nonblock(max_length - readable_bytes, @read_buffer_scratch)
243
244
  end
244
245
  end
245
246
  else
@@ -248,19 +249,15 @@ module Excon
248
249
  # Avoid allocating a new buffer string when the read buffer is empty
249
250
  @read_buffer = @socket.read_nonblock(@data[:chunk_size], @read_buffer)
250
251
  else
251
- @read_buffer << @socket.read_nonblock(@data[:chunk_size])
252
+ @read_buffer << @socket.read_nonblock(@data[:chunk_size], @read_buffer_scratch)
252
253
  end
253
254
  end
254
255
  end
255
- rescue OpenSSL::SSL::SSLError => error
256
- if error.message == 'read would block'
257
- if @read_buffer.empty?
258
- select_with_timeout(@socket, :read) && retry
259
- end
260
- else
261
- raise(error)
262
- end
263
- rescue *READ_RETRY_EXCEPTION_CLASSES
256
+ rescue OpenSSL::SSL::SSLError => e
257
+ raise(e) unless e.message == 'read would block'
258
+
259
+ select_with_timeout(@socket, :read) && retry if @read_buffer.empty?
260
+ rescue *READ_RETRY_EXCEPTION_CLASSES => e
264
261
  if @read_buffer.empty?
265
262
  # if we didn't read anything, try again...
266
263
  select_with_timeout(@socket, :read) && retry
@@ -299,12 +296,10 @@ module Excon
299
296
 
300
297
  def read_block(max_length)
301
298
  @socket.read(max_length)
302
- rescue OpenSSL::SSL::SSLError => error
303
- if error.message == 'read would block'
304
- select_with_timeout(@socket, :read) && retry
305
- else
306
- raise(error)
307
- end
299
+ rescue OpenSSL::SSL::SSLError => e
300
+ select_with_timeout(@socket, :read) && retry if e.message == 'read would block'
301
+
302
+ raise(e)
308
303
  rescue *READ_RETRY_EXCEPTION_CLASSES
309
304
  select_with_timeout(@socket, :read) && retry
310
305
  rescue EOFError
@@ -327,12 +322,10 @@ module Excon
327
322
  else
328
323
  raise error
329
324
  end
330
- rescue OpenSSL::SSL::SSLError, *WRITE_RETRY_EXCEPTION_CLASSES => error
331
- if error.is_a?(OpenSSL::SSL::SSLError) && error.message != 'write would block'
332
- raise error
333
- else
334
- select_with_timeout(@socket, :write) && retry
335
- end
325
+ rescue OpenSSL::SSL::SSLError, *WRITE_RETRY_EXCEPTION_CLASSES => e
326
+ raise e if e.is_a?(OpenSSL::SSL::SSLError) && e.message != 'write would block'
327
+
328
+ select_with_timeout(@socket, :write) && retry
336
329
  end
337
330
 
338
331
  # Fast, common case.
@@ -348,12 +341,10 @@ module Excon
348
341
 
349
342
  def write_block(data)
350
343
  @socket.write(data)
351
- rescue OpenSSL::SSL::SSLError, *WRITE_RETRY_EXCEPTION_CLASSES => error
352
- if error.is_a?(OpenSSL::SSL::SSLError) && error.message != 'write would block'
353
- raise error
354
- else
355
- select_with_timeout(@socket, :write) && retry
356
- end
344
+ rescue OpenSSL::SSL::SSLError, *WRITE_RETRY_EXCEPTION_CLASSES => e
345
+ raise e if e.is_a?(OpenSSL::SSL::SSLError) && e.message != 'write would block'
346
+
347
+ select_with_timeout(@socket, :write) && retry
357
348
  end
358
349
 
359
350
  def select_with_timeout(socket, type)
@@ -373,25 +364,19 @@ module Excon
373
364
  end
374
365
 
375
366
  select = case type
376
- when :connect_read
377
- IO.select([socket], nil, nil, timeout)
378
- when :connect_write
379
- IO.select(nil, [socket], nil, timeout)
380
- when :read
381
- IO.select([socket], nil, nil, timeout)
382
- when :write
383
- IO.select(nil, [socket], nil, timeout)
384
- end
367
+ when :connect_read, :read
368
+ IO.select([socket], nil, nil, timeout)
369
+ when :connect_write, :write
370
+ IO.select(nil, [socket], nil, timeout)
371
+ end
385
372
 
386
- select || raise(Excon::Errors::Timeout.new("#{timeout_kind} timeout reached"))
373
+ select || raise(Excon::Errors::Timeout.new, "#{timeout_kind} timeout reached")
387
374
  end
388
375
 
389
376
  def unpacked_sockaddr
390
377
  @unpacked_sockaddr ||= ::Socket.unpack_sockaddr_in(@socket.to_io.getsockname)
391
378
  rescue ArgumentError => e
392
- unless e.message == 'not an AF_INET/AF_INET6 sockaddr'
393
- raise
394
- end
379
+ raise unless e.message == 'not an AF_INET/AF_INET6 sockaddr'
395
380
  end
396
381
 
397
382
  # Returns the remaining time in seconds until we reach the deadline for the request timeout.
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ # SOCKS5 protocol implementation (RFC 1928, RFC 1929)
5
+ # Shared module for SOCKS5Socket and SOCKS5SSLSocket
6
+ module SOCKS5
7
+ SOCKS5_VERSION = 0x05
8
+ SOCKS5_RESERVED = 0x00
9
+
10
+ # Authentication methods
11
+ SOCKS5_NO_AUTH = 0x00
12
+ SOCKS5_AUTH_USERNAME_PASSWORD = 0x02
13
+ SOCKS5_NO_ACCEPTABLE_AUTH = 0xFF
14
+
15
+ # Commands
16
+ SOCKS5_CMD_CONNECT = 0x01
17
+
18
+ # Address types
19
+ SOCKS5_ATYP_IPV4 = 0x01
20
+ SOCKS5_ATYP_DOMAIN = 0x03
21
+ SOCKS5_ATYP_IPV6 = 0x04
22
+
23
+ # Reply codes
24
+ SOCKS5_SUCCESS = 0x00
25
+ SOCKS5_ERRORS = {
26
+ 0x01 => 'General SOCKS server failure',
27
+ 0x02 => 'Connection not allowed by ruleset',
28
+ 0x03 => 'Network unreachable',
29
+ 0x04 => 'Host unreachable',
30
+ 0x05 => 'Connection refused',
31
+ 0x06 => 'TTL expired',
32
+ 0x07 => 'Command not supported',
33
+ 0x08 => 'Address type not supported'
34
+ }.freeze
35
+
36
+ # Maximum hostname length per RFC 1928
37
+ MAX_HOSTNAME_LENGTH = 255
38
+
39
+ private
40
+
41
+ # Parse SOCKS5 proxy string into components
42
+ # @param proxy_string [String] Proxy specification in various formats
43
+ # @return [Array<String, String, String, String>] host, port, user, pass
44
+ def parse_socks5_proxy(proxy_string)
45
+ # Support formats:
46
+ # host:port
47
+ # user:pass@host:port
48
+ # socks5://host:port
49
+ # socks5://user:pass@host:port
50
+ proxy_string = proxy_string.to_s.sub(%r{^socks5://}, '')
51
+
52
+ user = nil
53
+ pass = nil
54
+
55
+ if proxy_string.include?('@')
56
+ auth, host_port = proxy_string.split('@', 2)
57
+ user, pass = auth.split(':', 2)
58
+ else
59
+ host_port = proxy_string
60
+ end
61
+
62
+ host, port = host_port.split(':', 2)
63
+ port ||= '1080'
64
+
65
+ [host, port, user, pass]
66
+ end
67
+
68
+ # Perform SOCKS5 authentication handshake
69
+ def socks5_authenticate
70
+ auth_methods = if @proxy_user && @proxy_pass
71
+ [SOCKS5_NO_AUTH, SOCKS5_AUTH_USERNAME_PASSWORD]
72
+ else
73
+ [SOCKS5_NO_AUTH]
74
+ end
75
+
76
+ greeting = [SOCKS5_VERSION, auth_methods.length, *auth_methods].pack('C*')
77
+ @socket.write(greeting)
78
+
79
+ response = socks5_read_exactly(2)
80
+ version, chosen_method = response.unpack('CC')
81
+
82
+ if version != SOCKS5_VERSION
83
+ raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy returned invalid version: #{version}"))
84
+ end
85
+
86
+ case chosen_method
87
+ when SOCKS5_NO_AUTH
88
+ # No authentication required
89
+ when SOCKS5_AUTH_USERNAME_PASSWORD
90
+ unless @proxy_user && @proxy_pass
91
+ raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy requires authentication but no credentials provided'))
92
+ end
93
+ socks5_username_password_auth
94
+ when SOCKS5_NO_ACCEPTABLE_AUTH
95
+ raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy: no acceptable authentication methods'))
96
+ else
97
+ raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy: unsupported authentication method #{chosen_method}"))
98
+ end
99
+ end
100
+
101
+ # RFC 1929: Username/Password Authentication
102
+ def socks5_username_password_auth
103
+ auth_request = [
104
+ 0x01, # auth protocol version
105
+ @proxy_user.bytesize,
106
+ @proxy_user,
107
+ @proxy_pass.bytesize,
108
+ @proxy_pass
109
+ ].pack('CCA*CA*')
110
+
111
+ @socket.write(auth_request)
112
+
113
+ response = socks5_read_exactly(2)
114
+ _, status = response.unpack('CC')
115
+
116
+ unless status == 0x00
117
+ raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy authentication failed'))
118
+ end
119
+ end
120
+
121
+ # Request connection to target through SOCKS5 proxy
122
+ def socks5_connect(host, port)
123
+ if host.bytesize > MAX_HOSTNAME_LENGTH
124
+ raise Excon::Error::Socket.new(Exception.new("SOCKS5: hostname exceeds maximum length of #{MAX_HOSTNAME_LENGTH} bytes"))
125
+ end
126
+
127
+ # Build CONNECT request with domain name (let proxy resolve DNS)
128
+ request = [SOCKS5_VERSION, SOCKS5_CMD_CONNECT, SOCKS5_RESERVED].pack('CCC')
129
+ request += [SOCKS5_ATYP_DOMAIN, host.bytesize, host].pack('CCA*')
130
+ request += [port.to_i].pack('n')
131
+
132
+ @socket.write(request)
133
+
134
+ response = socks5_read_exactly(4)
135
+ version, reply, _, atyp = response.unpack('CCCC')
136
+
137
+ if version != SOCKS5_VERSION
138
+ raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy returned invalid version: #{version}"))
139
+ end
140
+
141
+ unless reply == SOCKS5_SUCCESS
142
+ error_msg = SOCKS5_ERRORS[reply] || "Unknown error (#{reply})"
143
+ raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy connect failed: #{error_msg}"))
144
+ end
145
+
146
+ # Read and discard bound address (not needed for CONNECT)
147
+ socks5_read_bound_address(atyp)
148
+ end
149
+
150
+ def socks5_read_bound_address(atyp)
151
+ case atyp
152
+ when SOCKS5_ATYP_IPV4
153
+ socks5_read_exactly(4 + 2) # 4 bytes IP + 2 bytes port
154
+ when SOCKS5_ATYP_DOMAIN
155
+ domain_len = socks5_read_exactly(1).unpack1('C')
156
+ socks5_read_exactly(domain_len + 2)
157
+ when SOCKS5_ATYP_IPV6
158
+ socks5_read_exactly(16 + 2) # 16 bytes IP + 2 bytes port
159
+ else
160
+ raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy returned unknown address type: #{atyp}"))
161
+ end
162
+ end
163
+
164
+ # Read exact number of bytes with timeout support
165
+ def socks5_read_exactly(nbytes)
166
+ data = ''.dup
167
+ deadline = @data[:read_timeout] ? Time.now + @data[:read_timeout] : nil
168
+
169
+ while data.bytesize < nbytes
170
+ if deadline
171
+ remaining = deadline - Time.now
172
+ if remaining <= 0
173
+ raise Excon::Error::Timeout.new('SOCKS5 read timeout')
174
+ end
175
+ ready = IO.select([@socket], nil, nil, remaining)
176
+ unless ready
177
+ raise Excon::Error::Timeout.new('SOCKS5 read timeout')
178
+ end
179
+ end
180
+
181
+ chunk = @socket.read_nonblock(nbytes - data.bytesize, exception: false)
182
+ case chunk
183
+ when :wait_readable
184
+ IO.select([@socket], nil, nil, deadline ? [deadline - Time.now, 0].max : nil)
185
+ when nil, ''
186
+ raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy connection closed unexpectedly'))
187
+ else
188
+ data << chunk
189
+ end
190
+ end
191
+ data
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ class SOCKS5Socket < Socket
5
+ include SOCKS5
6
+
7
+ def initialize(data = {})
8
+ @socks5_proxy = data[:socks5_proxy]
9
+ @proxy_host, @proxy_port, @proxy_user, @proxy_pass = parse_socks5_proxy(@socks5_proxy)
10
+ super(data)
11
+ end
12
+
13
+ private
14
+
15
+ # Proxy-swap pattern: temporarily set @data[:proxy] to the SOCKS5 proxy
16
+ # so that Socket#connect routes the TCP connection there (inheriting DNS
17
+ # resolution, nonblock, retry, keepalive, reuseaddr, remote_ip tracking).
18
+ # After TCP is up, clear :proxy and run the SOCKS5 handshake.
19
+ def connect
20
+ @data[:proxy] = {
21
+ host: @proxy_host,
22
+ hostname: @proxy_host,
23
+ port: @proxy_port.to_i
24
+ }
25
+
26
+ begin
27
+ super
28
+ ensure
29
+ @data.delete(:proxy)
30
+ end
31
+
32
+ socks5_authenticate
33
+ socks5_connect(@data[:host], @data[:port])
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ class SOCKS5SSLSocket < SSLSocket
5
+ include SOCKS5
6
+
7
+ def initialize(data = {})
8
+ @socks5_proxy = data[:socks5_proxy]
9
+ @proxy_host, @proxy_port, @proxy_user, @proxy_pass = parse_socks5_proxy(@socks5_proxy)
10
+ super(data)
11
+ end
12
+
13
+ private
14
+
15
+ # Proxy-swap pattern (same as SOCKS5Socket#connect).
16
+ #
17
+ # Call chain:
18
+ # SOCKS5SSLSocket#initialize -> super (SSLSocket#initialize)
19
+ # -> super (Socket#initialize) -> connect
20
+ # -> SOCKS5SSLSocket#connect (this method)
21
+ # -> super -> SSLSocket#connect -> Socket#connect (TCP to proxy)
22
+ # -> SOCKS5 handshake on raw TCP socket
23
+ # <- returns to SSLSocket#initialize
24
+ # -> @data[:proxy] is nil, so HTTP CONNECT is skipped (line 111)
25
+ # -> SSL wrapping on the SOCKS5-tunneled socket
26
+ def connect
27
+ @data[:proxy] = {
28
+ host: @proxy_host,
29
+ hostname: @proxy_host,
30
+ port: @proxy_port.to_i
31
+ }
32
+
33
+ begin
34
+ super
35
+ ensure
36
+ # Clear :proxy so SSLSocket#initialize skips HTTP CONNECT (line 111)
37
+ @data.delete(:proxy)
38
+ end
39
+
40
+ socks5_authenticate
41
+ socks5_connect(@data[:host], @data[:port])
42
+ end
43
+ end
44
+ end
data/lib/excon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Excon
4
- VERSION = '1.3.0'
4
+ VERSION = "1.8.0"
5
5
  end
data/lib/excon.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  $:.unshift(File.dirname(__FILE__)) unless
4
4
  $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
5
5
 
6
- require 'cgi'
6
+ require 'cgi/escape'
7
7
  require 'forwardable'
8
8
  require 'ipaddr'
9
9
  require 'openssl'
@@ -27,6 +27,7 @@ require 'excon/middlewares/mock'
27
27
  require 'excon/middlewares/response_parser'
28
28
 
29
29
  require 'excon/error'
30
+ require 'excon/resolver_factory'
30
31
  require 'excon/constants'
31
32
  require 'excon/utils'
32
33
 
@@ -35,10 +36,12 @@ require 'excon/headers'
35
36
  require 'excon/response'
36
37
  require 'excon/middlewares/escape_path'
37
38
  require 'excon/middlewares/redirect_follower'
38
- require 'excon/middlewares/capture_cookies'
39
39
  require 'excon/pretty_printer'
40
40
  require 'excon/socket'
41
41
  require 'excon/ssl_socket'
42
+ require 'excon/socks5'
43
+ require 'excon/socks5_socket'
44
+ require 'excon/socks5_ssl_socket'
42
45
  require 'excon/instrumentors/standard_instrumentor'
43
46
  require 'excon/instrumentors/logging_instrumentor'
44
47
  require 'excon/unix_socket'