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.
- checksums.yaml +4 -4
- data/README.md +15 -2
- data/data/cacert.pem +277 -817
- data/excon.gemspec +1 -4
- data/lib/excon/connection.rb +26 -6
- data/lib/excon/constants.rb +3 -0
- data/lib/excon/error.rb +1 -0
- data/lib/excon/middlewares/redirect_follower.rb +16 -4
- data/lib/excon/resolver_factory.rb +18 -0
- data/lib/excon/socket.rb +29 -44
- data/lib/excon/socks5.rb +194 -0
- data/lib/excon/socks5_socket.rb +36 -0
- data/lib/excon/socks5_ssl_socket.rb +44 -0
- data/lib/excon/version.rb +1 -1
- data/lib/excon.rb +5 -2
- metadata +7 -51
- data/lib/excon/middlewares/capture_cookies.rb +0 -32
- data/lib/excon/test/plugin/server/exec.rb +0 -26
- data/lib/excon/test/plugin/server/puma.rb +0 -23
- data/lib/excon/test/plugin/server/unicorn.rb +0 -40
- data/lib/excon/test/plugin/server/webrick.rb +0 -26
- data/lib/excon/test/server.rb +0 -99
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.
|
|
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')
|
data/lib/excon/connection.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
data/lib/excon/constants.rb
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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] ||
|
|
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 =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 =>
|
|
303
|
-
if
|
|
304
|
-
|
|
305
|
-
|
|
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 =>
|
|
331
|
-
if
|
|
332
|
-
|
|
333
|
-
|
|
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 =>
|
|
352
|
-
if
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
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.
|
data/lib/excon/socks5.rb
ADDED
|
@@ -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
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'
|