tcp-client 0.6.0 → 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,70 +1,145 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class TCPClient
4
- class NoOpenSSL < RuntimeError
4
+ #
5
+ # Raised when a SSL connection should be establshed but the OpenSSL gem is not available.
6
+ #
7
+ class NoOpenSSLError < RuntimeError
5
8
  def initialize
6
9
  super('OpenSSL is not available')
7
10
  end
8
11
  end
9
12
 
10
- class NoBlockGiven < ArgumentError
13
+ #
14
+ # Raised when a method requires a callback block but no such block is specified.
15
+ #
16
+ class NoBlockGivenError < ArgumentError
11
17
  def initialize
12
18
  super('no block given')
13
19
  end
14
20
  end
15
21
 
16
- class InvalidDeadLine < ArgumentError
22
+ #
23
+ # Raised when a an invalid timeout value was specified.
24
+ #
25
+ class InvalidDeadLineError < ArgumentError
17
26
  def initialize(timeout)
18
27
  super("invalid deadline - #{timeout}")
19
28
  end
20
29
  end
21
30
 
22
- class UnknownAttribute < ArgumentError
31
+ #
32
+ # Raised by {Configuration} when an undefined attribute should be set.
33
+ #
34
+ class UnknownAttributeError < ArgumentError
23
35
  def initialize(attribute)
24
36
  super("unknown attribute - #{attribute}")
25
37
  end
26
38
  end
27
39
 
28
- class NotAnException < TypeError
40
+ #
41
+ # Raised when a given timeout exception parameter is not an exception class.
42
+ #
43
+ class NotAnExceptionError < TypeError
29
44
  def initialize(object)
30
45
  super("exception class required - #{object.inspect}")
31
46
  end
32
47
  end
33
48
 
34
- class NotConnected < IOError
49
+ #
50
+ # Base exception class for all network related errors.
51
+ #
52
+ # Will be raised for any system level network error when {Configuration.normalize_network_errors} is configured.
53
+ #
54
+ # You should catch this exception class when you like to handle any relevant {TCPClient} error.
55
+ #
56
+ class NetworkError < StandardError
57
+ end
58
+
59
+ #
60
+ # Raised when a {TCPClient} instance should read/write from/to the network but is not connected.
61
+ #
62
+ class NotConnectedError < NetworkError
35
63
  def initialize
36
64
  super('client not connected')
37
65
  end
38
66
  end
39
67
 
40
- class TimeoutError < IOError
68
+ #
69
+ # Base exception class for a detected timeout.
70
+ #
71
+ # You should catch this exception class when you like to handle any timeout error.
72
+ #
73
+ class TimeoutError < NetworkError
74
+ #
75
+ # Initializes the instance with an optional message.
76
+ #
77
+ # the message will be generated from {#action} when not specified.
78
+ # @overload initialize
79
+ # @overload initialize(message)
80
+ #
81
+ # @param message [String, #to_s] the error message
82
+ #
41
83
  def initialize(message = nil)
42
84
  super(message || "unable to #{action} in time")
43
85
  end
44
86
 
87
+ #
88
+ # @return [Symbol] the action which timed out
89
+ #
45
90
  def action
46
91
  :process
47
92
  end
48
93
  end
49
94
 
95
+ #
96
+ # Raised by default whenever a {TCPClient.connect} timed out.
97
+ #
50
98
  class ConnectTimeoutError < TimeoutError
99
+ #
100
+ # @return [Symbol] the action which timed out: +:connect+
101
+ #
51
102
  def action
52
103
  :connect
53
104
  end
54
105
  end
55
106
 
107
+ #
108
+ # Raised by default whenever a {TCPClient.read} timed out.
109
+ #
56
110
  class ReadTimeoutError < TimeoutError
111
+ #
112
+ # @return [Symbol] the action which timed out: +:read+
113
+ #
57
114
  def action
58
115
  :read
59
116
  end
60
117
  end
61
118
 
119
+ #
120
+ # Raised by default whenever a {TCPClient.write} timed out.
121
+ #
62
122
  class WriteTimeoutError < TimeoutError
123
+ #
124
+ # @return [Symbol] the action which timed out: +:write+
125
+ #
63
126
  def action
64
127
  :write
65
128
  end
66
129
  end
67
130
 
68
- Timeout = TimeoutError # backward compatibility
69
- deprecate_constant(:Timeout)
131
+ NoOpenSSL = NoOpenSSLError # @!visibility private
132
+ NoBlockGiven = NoBlockGivenError # @!visibility private
133
+ InvalidDeadLine = InvalidDeadLineError # @!visibility private
134
+ UnknownAttribute = UnknownAttributeError # @!visibility private
135
+ NotAnException = NotAnExceptionError # @!visibility private
136
+ NotConnected = NotConnectedError # @!visibility private
137
+ deprecate_constant(
138
+ :NoOpenSSL,
139
+ :NoBlockGiven,
140
+ :InvalidDeadLine,
141
+ :UnknownAttribute,
142
+ :NotAnException,
143
+ :NotConnected
144
+ )
70
145
  end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module IOWithDeadlineMixin
3
+ # @!visibility private
4
+ module IOWithDeadlineMixin # :nodoc:
4
5
  def self.included(mod)
5
6
  methods = mod.instance_methods
6
7
  if methods.index(:wait_writable) && methods.index(:wait_readable)
7
8
  mod.include(ViaWaitMethod)
9
+ elsif methods.index(:to_io)
10
+ mod.include(ViaIOWaitMethod)
8
11
  else
9
12
  mod.include(ViaSelect)
10
13
  end
@@ -66,6 +69,25 @@ module IOWithDeadlineMixin
66
69
  end
67
70
  end
68
71
 
72
+ module ViaIOWaitMethod
73
+ private def with_deadline(deadline, exception)
74
+ loop do
75
+ case ret = yield
76
+ when :wait_writable
77
+ remaining_time = deadline.remaining_time or raise(exception)
78
+ raise(exception) if to_io.wait_writable(remaining_time).nil?
79
+ when :wait_readable
80
+ remaining_time = deadline.remaining_time or raise(exception)
81
+ raise(exception) if to_io.wait_readable(remaining_time).nil?
82
+ else
83
+ return ret
84
+ end
85
+ end
86
+ rescue Errno::ETIMEDOUT
87
+ raise(exception)
88
+ end
89
+ end
90
+
69
91
  module ViaSelect
70
92
  private def with_deadline(deadline, exception)
71
93
  loop do
@@ -85,5 +107,5 @@ module IOWithDeadlineMixin
85
107
  end
86
108
  end
87
109
 
88
- private_constant(:ViaWaitMethod, :ViaSelect)
110
+ private_constant(:ViaWaitMethod, :ViaIOWaitMethod, :ViaSelect)
89
111
  end
@@ -18,6 +18,7 @@ class TCPClient
18
18
  super(socket, create_context(ssl_params))
19
19
  self.sync_close = true
20
20
  self.hostname = address.hostname
21
+ check_new_session if @new_session
21
22
  deadline.valid? ? connect_with_deadline(deadline, exception) : connect
22
23
  post_connection_check(address.hostname) if should_verify?(ssl_params)
23
24
  end
@@ -25,7 +26,19 @@ class TCPClient
25
26
  private
26
27
 
27
28
  def create_context(ssl_params)
28
- OpenSSL::SSL::SSLContext.new.tap { |ctx| ctx.set_params(ssl_params) }
29
+ @new_session = nil
30
+ ::OpenSSL::SSL::SSLContext.new.tap do |ctx|
31
+ ctx.set_params(ssl_params)
32
+ ctx.session_cache_mode = CONTEXT_CACHE_MODE
33
+ ctx.session_new_cb = proc { |_, sess| @new_session = sess }
34
+ end
35
+ end
36
+
37
+ def check_new_session
38
+ time = @new_session.time.to_f + @new_session.timeout
39
+ if Process.clock_gettime(Process::CLOCK_REALTIME) < time
40
+ self.session = @new_session
41
+ end
29
42
  end
30
43
 
31
44
  def connect_with_deadline(deadline, exception)
@@ -33,8 +46,13 @@ class TCPClient
33
46
  end
34
47
 
35
48
  def should_verify?(ssl_params)
36
- ssl_params[:verify_mode] != OpenSSL::SSL::VERIFY_NONE
49
+ ssl_params[:verify_mode] != ::OpenSSL::SSL::VERIFY_NONE &&
50
+ context.verify_hostname
37
51
  end
52
+
53
+ CONTEXT_CACHE_MODE =
54
+ ::OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT |
55
+ ::OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE
38
56
  end
39
57
 
40
58
  private_constant(:SSLSocket)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class TCPClient
4
- VERSION = '0.6.0'
4
+ #
5
+ # The current gem version.
6
+ #
7
+ VERSION = '0.9.1'
5
8
  end
data/lib/tcp-client.rb CHANGED
@@ -9,8 +9,44 @@ require_relative 'tcp-client/configuration'
9
9
  require_relative 'tcp-client/default_configuration'
10
10
  require_relative 'tcp-client/version'
11
11
 
12
+ #
13
+ # Client class to communicate with a server via TCP w/o SSL.
14
+ #
15
+ # All connect/read/write actions can be monitored to ensure that all actions
16
+ # terminate before given time limits - or raise an exception.
17
+ #
18
+ # @example - request to Google.com and limit all network interactions to 1.5 seconds
19
+ # TCPClient.with_deadline(1.5, 'www.google.com:443') do |client|
20
+ # client.write("GET / HTTP/1.1\r\nHost: www.google.com\r\n\r\n")
21
+ # client.read(12)
22
+ # end
23
+ # # => "HTTP/1.1 200"
24
+ #
25
+ #
12
26
  class TCPClient
13
- def self.open(address, configuration = Configuration.default)
27
+ #
28
+ # Creates a new instance which is connected to the server on the given
29
+ # address and uses the given or the {.default_configuration}.
30
+ #
31
+ # If an optional block is given, then the block's result is returned and the
32
+ # connection will be closed when the block execution ends.
33
+ # This can be used to create an ad-hoc connection which is garanteed to be
34
+ # closed.
35
+ #
36
+ # If no block is giiven the connected client instance is returned.
37
+ # This can be used as a shorthand to create & connect a client.
38
+ #
39
+ # @param address [Address, String, Addrinfo, Integer] the address to connect to, see {Address#initialize} for valid formats
40
+ # @param configuration [Configuration] the {Configuration} to be used for this instance
41
+ #
42
+ # @yieldparam client [TCPClient] the connected client
43
+ # @yieldreturn [Object] any result
44
+ #
45
+ # @return [Object, TCPClient] the block result or the connected client
46
+ #
47
+ # @see #connect
48
+ #
49
+ def self.open(address, configuration = nil)
14
50
  client = new
15
51
  client.connect(Address.new(address), configuration)
16
52
  block_given? ? yield(client) : client
@@ -18,13 +54,31 @@ class TCPClient
18
54
  client.close if block_given?
19
55
  end
20
56
 
21
- def self.with_deadline(
22
- timeout,
23
- address,
24
- configuration = Configuration.default
25
- )
57
+ #
58
+ # Yields a new instance which is connected to the server on the given
59
+ # address and uses the given or the {.default_configuration}.
60
+ # It ensures to close the connection when the block execution ends.
61
+ # It also limits all {#read} and {#write} actions within the block to a given
62
+ # time.
63
+ #
64
+ # This can be used to create an ad-hoc connection which is garanteed to be
65
+ # closed and which read/write calls should not last longer than the timeout
66
+ # limit.
67
+ #
68
+ # @param timeout [Numeric] maximum time in seconds for all {#read} and {#write} calls within the block
69
+ # @param address [Address, String, Addrinfo, Integer] the address to connect to, see {Address#initialize} for valid formats
70
+ # @param configuration [Configuration] the {Configuration} to be used for this instance
71
+ #
72
+ # @yieldparam client [TCPClient] the connected client
73
+ # @yieldreturn [Object] any result
74
+ #
75
+ # @return [Object] the block result
76
+ #
77
+ # @see #with_deadline
78
+ #
79
+ def self.with_deadline(timeout, address, configuration = nil)
26
80
  client = nil
27
- raise(NoBlockGiven) unless block_given?
81
+ raise(NoBlockGivenError) unless block_given?
28
82
  address = Address.new(address)
29
83
  client = new
30
84
  client.with_deadline(timeout) do
@@ -34,68 +88,154 @@ class TCPClient
34
88
  client&.close
35
89
  end
36
90
 
37
- attr_reader :address, :configuration
91
+ #
92
+ # @return [Address] the address used for this client
93
+ #
94
+ attr_reader :address
38
95
 
39
- def initialize
40
- @socket = @address = @deadline = @configuration = nil
96
+ #
97
+ # @return [Configuration] the configuration used by this client.
98
+ #
99
+ attr_reader :configuration
100
+
101
+ #
102
+ # @attribute [r] closed?
103
+ # @return [Boolean] true when the connection is closed, false when connected
104
+ #
105
+ def closed?
106
+ @socket.nil? || @socket.closed?
41
107
  end
42
108
 
109
+ #
110
+ # @return [String] the currently used address as text.
111
+ #
112
+ # @see Address#to_s
113
+ #
43
114
  def to_s
44
115
  @address&.to_s || ''
45
116
  end
46
117
 
47
- def connect(address, configuration, timeout: nil, exception: nil)
118
+ #
119
+ # Establishes a new connection to a given address.
120
+ #
121
+ # It accepts a connection-specific configuration or uses the global {.default_configuration}. The {#configuration} used by this instance will
122
+ # be a copy of the configuration used for this method call. This allows to
123
+ # configure the behavior per connection.
124
+ #
125
+ # @param address [Address, String, Addrinfo, Integer] the address to connect to, see {Address#initialize} for valid formats
126
+ # @param configuration [Configuration] the {Configuration} to be used for this instance
127
+ # @param timeout [Numeric] maximum time in seconds to read; used to override the configuration's +connect_timeout+.
128
+ # @param exception [Class] exception class to be used when the read timeout reached; used to override the configuration's +connect_timeout_error+.
129
+ #
130
+ # @return [self]
131
+ #
132
+ # @raise {NoOpenSSLError} if SSL should be used but OpenSSL is not avail
133
+ #
134
+ def connect(address, configuration = nil, timeout: nil, exception: nil)
48
135
  close if @socket
49
- raise(NoOpenSSL) if configuration.ssl? && !defined?(SSLSocket)
50
136
  @address = Address.new(address)
51
- @configuration = configuration.dup.freeze
137
+ @configuration = (configuration || Configuration.default).dup
138
+ raise(NoOpenSSLError) if @configuration.ssl? && !defined?(SSLSocket)
52
139
  @socket = create_socket(timeout, exception)
53
140
  self
54
141
  end
55
142
 
143
+ #
144
+ # Close the current connection.
145
+ #
146
+ # @return [self]
147
+ #
56
148
  def close
57
149
  @socket&.close
58
150
  self
59
- rescue IOError
151
+ rescue *NETWORK_ERRORS
60
152
  self
61
153
  ensure
62
154
  @socket = @deadline = nil
63
155
  end
64
156
 
65
- def closed?
66
- @socket.nil? || @socket.closed?
67
- end
68
-
157
+ #
158
+ # Executes a block with a given overall timeout.
159
+ #
160
+ # When you like to ensure that a complete read/write communication sequence
161
+ # with the server is finished before a given amount of time you can use this
162
+ # method to define such a deadline.
163
+ #
164
+ # @example - ensure to send a welcome message and receive a 64 byte answer from server
165
+ # answer = client.with_deadline(2.5) do
166
+ # client.write('Helo')
167
+ # client.read(64)
168
+ # end
169
+ #
170
+ # @param timeout [Numeric] maximum time in seconds for all {#read} and {#write} calls within the block
171
+ #
172
+ # @yieldparam client [TCPClient] self
173
+ #
174
+ # @return [Object] result of the given block
175
+ #
176
+ # @raise [NoBlockGivenError] if the block is missing
177
+ #
69
178
  def with_deadline(timeout)
70
179
  previous_deadline = @deadline
71
- raise(NoBlockGiven) unless block_given?
180
+ raise(NoBlockGivenError) unless block_given?
72
181
  @deadline = Deadline.new(timeout)
73
- raise(InvalidDeadLine, timeout) unless @deadline.valid?
182
+ raise(InvalidDeadLineError, timeout) unless @deadline.valid?
74
183
  yield(self)
75
184
  ensure
76
185
  @deadline = previous_deadline
77
186
  end
78
187
 
188
+ #
189
+ # Read the given nbytes or the next available buffer from server.
190
+ #
191
+ # @param nbytes [Integer] the number of bytes to read
192
+ # @param timeout [Numeric] maximum time in seconds to read; used to override the configuration's +read_timeout+.
193
+ # @param exception [Class] exception class to be used when the read timeout reached; used to override the configuration's +read_timeout_error+.
194
+ #
195
+ # @return [String] buffer read
196
+ #
197
+ # @raise [NotConnectedError] if {#connect} was not called before
198
+ #
79
199
  def read(nbytes = nil, timeout: nil, exception: nil)
80
- raise(NotConnected) if closed?
200
+ raise(NotConnectedError) if closed?
81
201
  deadline = create_deadline(timeout, configuration.read_timeout)
82
- return @socket.read(nbytes) unless deadline.valid?
202
+ return stem_errors { @socket.read(nbytes) } unless deadline.valid?
83
203
  exception ||= configuration.read_timeout_error
84
- @socket.read_with_deadline(nbytes, deadline, exception)
204
+ stem_errors(exception) do
205
+ @socket.read_with_deadline(nbytes, deadline, exception)
206
+ end
85
207
  end
86
208
 
87
- def write(*msg, timeout: nil, exception: nil)
88
- raise(NotConnected) if closed?
209
+ #
210
+ # Write the given messages to the server.
211
+ #
212
+ # @param messages [Array<String>] messages to write
213
+ # @param timeout [Numeric] maximum time in seconds to read; used to override the configuration's +write_timeout+.
214
+ # @param exception [Class] exception class to be used when the read timeout reached; used to override the configuration's +write_timeout_error+.
215
+ #
216
+ # @return [Integer] bytes written
217
+ #
218
+ # @raise [NotConnectedError] if {#connect} was not called before
219
+ #
220
+ def write(*messages, timeout: nil, exception: nil)
221
+ raise(NotConnectedError) if closed?
89
222
  deadline = create_deadline(timeout, configuration.write_timeout)
90
- return @socket.write(*msg) unless deadline.valid?
223
+ return stem_errors { @socket.write(*messages) } unless deadline.valid?
91
224
  exception ||= configuration.write_timeout_error
92
- msg.sum do |chunk|
93
- @socket.write_with_deadline(chunk.b, deadline, exception)
225
+ stem_errors(exception) do
226
+ messages.sum do |chunk|
227
+ @socket.write_with_deadline(chunk.b, deadline, exception)
228
+ end
94
229
  end
95
230
  end
96
231
 
232
+ #
233
+ # Flush all internal buffers (write all through).
234
+ #
235
+ # @return [self]
236
+ #
97
237
  def flush
98
- @socket&.flush
238
+ stem_errors { @socket&.flush }
99
239
  self
100
240
  end
101
241
 
@@ -108,8 +248,34 @@ class TCPClient
108
248
  def create_socket(timeout, exception)
109
249
  deadline = create_deadline(timeout, configuration.connect_timeout)
110
250
  exception ||= configuration.connect_timeout_error
111
- @socket = TCPSocket.new(address, configuration, deadline, exception)
112
- return @socket unless configuration.ssl?
113
- SSLSocket.new(@socket, address, configuration, deadline, exception)
251
+ stem_errors(exception) do
252
+ @socket = TCPSocket.new(address, configuration, deadline, exception)
253
+ return @socket unless configuration.ssl?
254
+ SSLSocket.new(@socket, address, configuration, deadline, exception)
255
+ end
114
256
  end
257
+
258
+ def stem_errors(except = nil)
259
+ yield
260
+ rescue *NETWORK_ERRORS => e
261
+ raise unless configuration.normalize_network_errors
262
+ (except && e.is_a?(except)) ? raise : raise(NetworkError, e)
263
+ end
264
+
265
+ NETWORK_ERRORS =
266
+ [
267
+ Errno::EADDRNOTAVAIL,
268
+ Errno::ECONNABORTED,
269
+ Errno::ECONNREFUSED,
270
+ Errno::ECONNRESET,
271
+ Errno::EHOSTUNREACH,
272
+ Errno::EINVAL,
273
+ Errno::ENETUNREACH,
274
+ Errno::EPIPE,
275
+ IOError,
276
+ SocketError
277
+ ].tap do |errors|
278
+ errors << ::OpenSSL::SSL::SSLError if defined?(::OpenSSL::SSL::SSLError)
279
+ end.freeze
280
+ private_constant(:NETWORK_ERRORS)
115
281
  end
data/rakefile.rb CHANGED
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rake/clean'
4
- require 'rake/testtask'
5
4
  require 'bundler/gem_tasks'
5
+ require 'rspec/core/rake_task'
6
+ require 'yard'
6
7
 
7
8
  $stdout.sync = $stderr.sync = true
8
9
 
9
- CLOBBER << 'prj'
10
-
10
+ CLOBBER << 'prj' << 'doc'
11
11
  task(:default) { exec('rake --tasks') }
12
-
13
- Rake::TestTask.new(:test) do |task|
14
- task.pattern = 'test/**/*_test.rb'
15
- task.warning = task.verbose = true
16
- end
12
+ RSpec::Core::RakeTask.new { |task| task.ruby_opts = %w[-w] }
13
+ YARD::Rake::YardocTask.new { |task| task.stats_options = %w[--list-undoc] }
data/sample/google_ssl.rb CHANGED
@@ -2,23 +2,24 @@
2
2
 
3
3
  require_relative '../lib/tcp-client'
4
4
 
5
- # create a configuration.
6
- # - use TLS 1.2
5
+ # create a configuration:
7
6
  # - don't use internal buffering
7
+ # - use TLS 1.2 or TLS 1.3
8
8
  cfg =
9
9
  TCPClient::Configuration.create(
10
10
  buffered: false,
11
11
  ssl_params: {
12
- ssl_version: :TLSv1_2
12
+ min_version: :TLS1_2,
13
+ max_version: :TLS1_3
13
14
  }
14
15
  )
15
16
 
16
- # request to Google:
17
- # - limit all interactions to 0.5 seconds
17
+ # request to Google.com:
18
+ # - limit all network interactions to 1.5 seconds
18
19
  # - use the Configuration cfg
19
20
  # - send a simple HTTP get request
20
21
  # - read 12 byte: "HTTP/1.1 " + 3 byte HTTP status code
21
- TCPClient.with_deadline(0.5, 'www.google.com:443', cfg) do |client|
22
+ TCPClient.with_deadline(1.5, 'www.google.com:443', cfg) do |client|
22
23
  p client.write("GET / HTTP/1.1\r\nHost: www.google.com\r\n\r\n")
23
24
  p client.read(12)
24
25
  end
data/spec/helper.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+ require_relative '../lib/tcp-client'
5
+
6
+ $stdout.sync = $stderr.sync = true
7
+
8
+ RSpec.configure do |config|
9
+ config.disable_monkey_patching!
10
+ config.warnings = true
11
+ config.order = :random
12
+ end