tcp-client 0.7.0 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 582575d6b5a66a264900141fd5010a1a80a363e15d0ca369f262889d99d932d0
4
- data.tar.gz: a0c2335a50addbf5f424d869cd2931256a1334a0a075530ef3873d9065246834
3
+ metadata.gz: 14f787ad4e8e06910bfebf67755ae7142183df4c4fb090edce84aae7d497a2b6
4
+ data.tar.gz: 6349bea2eac6bac053c724e0c6a162b1fe99502e94cf1c6734854d97f194f37a
5
5
  SHA512:
6
- metadata.gz: b160bd656c4f6091a669cf65349e54aeaa2e7c057e0a5a3c896edf5e8ab91166a3a5cdafedfe84e4b04d5a79e0602fb2433c6ff9144e6881746f3607b8d8290b
7
- data.tar.gz: 6726ebdfbc777258e62b3d915fa7e4a57f7ae514d6259ce5f0051ee649294142acf09faac490901e32083072d7a68dd4ce71fa697e0bf05c0d9556c0d57bb6a5
6
+ metadata.gz: b53fafbf091a67a329832663c058b4e8243b36a779f8bc52cc7f37de556ad0aa016245e0a059489b972e6afaccfea7d26a63a735686d5f50d88036a2f4ecaca1
7
+ data.tar.gz: 4a6ff368e221073c5addfab51e31df1b094ee3f69e09ebe3f38d6d552ebc761b14c552a1a2cd90dcfd148b1ba34ebb1b2e3abfcea9622ded8413870f21a60285
data/README.md CHANGED
@@ -11,20 +11,22 @@ This Gem implements a TCP client with (optional) SSL support. It is an easy to u
11
11
  ```ruby
12
12
  require 'tcp-client'
13
13
 
14
- TCPClient.configure do |cfg|
15
- cfg.connect_timeout = 1 # limit connect time the server to 1 second
16
- cfg.ssl_params = { ssl_version: :TLSv1_2 } # use TLS 1.2
17
- end
18
-
19
- TCPClient.open('www.google.com:443') do |client|
20
- # next sequence should not last longer than 0.5 seconds
21
- client.with_deadline(0.5) do
22
- # simple HTTP get request
23
- pp client.write("GET / HTTP/1.1\r\nHost: www.google.com\r\n\r\n")
24
-
25
- # read "HTTP/1.1 " + 3 byte HTTP status code
26
- pp client.read(12)
27
- end
14
+ # create a configuration:
15
+ # - don't use internal buffering
16
+ # - use TLS 1.2 or TLS 1.3
17
+ cfg = TCPClient::Configuration.create(
18
+ buffered: false,
19
+ ssl_params: {min_version: :TLS1_2, max_version: :TLS1_3}
20
+ )
21
+
22
+ # request to Google.com:
23
+ # - limit all network interactions to 1.5 seconds
24
+ # - use the Configuration cfg
25
+ # - send a simple HTTP get request
26
+ # - read 12 byte: "HTTP/1.1 " + 3 byte HTTP status code
27
+ TCPClient.with_deadline(1.5, 'www.google.com:443', cfg) do |client|
28
+ client.write("GET / HTTP/1.1\r\nHost: www.google.com\r\n\r\n") # >= 40
29
+ client.read(12) # => "HTTP/1.1 200"
28
30
  end
29
31
  ```
30
32
 
@@ -41,13 +43,13 @@ gem 'tcp-client'
41
43
  and install it by running Bundler:
42
44
 
43
45
  ```bash
44
- $ bundle
46
+ bundle
45
47
  ```
46
48
 
47
49
  To install the gem globally use:
48
50
 
49
51
  ```bash
50
- $ gem install tcp-client
52
+ gem install tcp-client
51
53
  ```
52
54
 
53
55
  After that you need only a single line of code in your project to have all tools on board:
@@ -25,12 +25,16 @@ class TCPClient
25
25
  "#{@hostname}:#{@addrinfo.ip_port}"
26
26
  end
27
27
 
28
- def to_h
28
+ def to_hash
29
29
  { host: @hostname, port: @addrinfo.ip_port }
30
30
  end
31
31
 
32
+ def to_h(*args)
33
+ args.empty? ? to_hash : to_hash.slice(*args)
34
+ end
35
+
32
36
  def ==(other)
33
- to_h == other.to_h
37
+ to_hash == other.to_hash
34
38
  end
35
39
  alias eql? ==
36
40
 
@@ -13,6 +13,7 @@ class TCPClient
13
13
  attr_reader :buffered,
14
14
  :keep_alive,
15
15
  :reverse_lookup,
16
+ :normalize_network_errors,
16
17
  :connect_timeout,
17
18
  :read_timeout,
18
19
  :write_timeout,
@@ -27,6 +28,7 @@ class TCPClient
27
28
  @connect_timeout_error = ConnectTimeoutError
28
29
  @read_timeout_error = ReadTimeoutError
29
30
  @write_timeout_error = WriteTimeoutError
31
+ @normalize_network_errors = false
30
32
  options.each_pair { |attribute, value| set(attribute, value) }
31
33
  end
32
34
 
@@ -47,8 +49,8 @@ class TCPClient
47
49
 
48
50
  def ssl=(value)
49
51
  @ssl_params =
50
- if Hash === value
51
- Hash[value]
52
+ if value.respond_to?(:to_hash)
53
+ Hash[value.to_hash]
52
54
  else
53
55
  value ? {} : nil
54
56
  end
@@ -66,6 +68,10 @@ class TCPClient
66
68
  @reverse_lookup = value ? true : false
67
69
  end
68
70
 
71
+ def normalize_network_errors=(value)
72
+ @normalize_network_errors = value ? true : false
73
+ end
74
+
69
75
  def timeout=(seconds)
70
76
  @connect_timeout = @write_timeout = @read_timeout = seconds(seconds)
71
77
  end
@@ -103,23 +109,27 @@ class TCPClient
103
109
  @write_timeout_error = exception
104
110
  end
105
111
 
106
- def to_h
112
+ def to_hash
107
113
  {
108
114
  buffered: @buffered,
109
115
  keep_alive: @keep_alive,
110
116
  reverse_lookup: @reverse_lookup,
111
117
  connect_timeout: @connect_timeout,
112
- read_timeout: @read_timeout,
113
- write_timeout: @write_timeout,
114
118
  connect_timeout_error: @connect_timeout_error,
119
+ read_timeout: @read_timeout,
115
120
  read_timeout_error: @read_timeout_error,
121
+ write_timeout: @write_timeout,
116
122
  write_timeout_error: @write_timeout_error,
117
123
  ssl_params: @ssl_params
118
124
  }
119
125
  end
120
126
 
127
+ def to_h(*args)
128
+ args.empty? ? to_hash : to_hash.slice(*args)
129
+ end
130
+
121
131
  def ==(other)
122
- to_h == other.to_h
132
+ to_hash == other.to_hash
123
133
  end
124
134
  alias eql? ==
125
135
 
@@ -31,13 +31,15 @@ class TCPClient
31
31
  end
32
32
  end
33
33
 
34
- class NotConnectedError < IOError
34
+ NetworkError = Class.new(StandardError)
35
+
36
+ class NotConnectedError < NetworkError
35
37
  def initialize
36
38
  super('client not connected')
37
39
  end
38
40
  end
39
41
 
40
- class TimeoutError < IOError
42
+ class TimeoutError < NetworkError
41
43
  def initialize(message = nil)
42
44
  super(message || "unable to #{action} in time")
43
45
  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,9 +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 &&
37
50
  context.verify_hostname
38
51
  end
52
+
53
+ CONTEXT_CACHE_MODE =
54
+ ::OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT |
55
+ ::OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE
39
56
  end
40
57
 
41
58
  private_constant(:SSLSocket)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class TCPClient
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/tcp-client.rb CHANGED
@@ -10,7 +10,7 @@ require_relative 'tcp-client/default_configuration'
10
10
  require_relative 'tcp-client/version'
11
11
 
12
12
  class TCPClient
13
- def self.open(address, configuration = Configuration.default)
13
+ def self.open(address, configuration = nil)
14
14
  client = new
15
15
  client.connect(Address.new(address), configuration)
16
16
  block_given? ? yield(client) : client
@@ -18,11 +18,7 @@ class TCPClient
18
18
  client.close if block_given?
19
19
  end
20
20
 
21
- def self.with_deadline(
22
- timeout,
23
- address,
24
- configuration = Configuration.default
25
- )
21
+ def self.with_deadline(timeout, address, configuration = nil)
26
22
  client = nil
27
23
  raise(NoBlockGivenError) unless block_given?
28
24
  address = Address.new(address)
@@ -36,19 +32,15 @@ class TCPClient
36
32
 
37
33
  attr_reader :address, :configuration
38
34
 
39
- def initialize
40
- @socket = @address = @deadline = @configuration = nil
41
- end
42
-
43
35
  def to_s
44
36
  @address&.to_s || ''
45
37
  end
46
38
 
47
- def connect(address, configuration, timeout: nil, exception: nil)
39
+ def connect(address, configuration = nil, timeout: nil, exception: nil)
48
40
  close if @socket
49
41
  raise(NoOpenSSLError) if configuration.ssl? && !defined?(SSLSocket)
50
42
  @address = Address.new(address)
51
- @configuration = configuration.dup.freeze
43
+ @configuration = (configuration || Configuration.default).dup
52
44
  @socket = create_socket(timeout, exception)
53
45
  self
54
46
  end
@@ -56,7 +48,7 @@ class TCPClient
56
48
  def close
57
49
  @socket&.close
58
50
  self
59
- rescue IOError
51
+ rescue *NETWORK_ERRORS
60
52
  self
61
53
  ensure
62
54
  @socket = @deadline = nil
@@ -79,23 +71,27 @@ class TCPClient
79
71
  def read(nbytes = nil, timeout: nil, exception: nil)
80
72
  raise(NotConnectedError) if closed?
81
73
  deadline = create_deadline(timeout, configuration.read_timeout)
82
- return @socket.read(nbytes) unless deadline.valid?
74
+ return stem_errors { @socket.read(nbytes) } unless deadline.valid?
83
75
  exception ||= configuration.read_timeout_error
84
- @socket.read_with_deadline(nbytes, deadline, exception)
76
+ stem_errors(exception) do
77
+ @socket.read_with_deadline(nbytes, deadline, exception)
78
+ end
85
79
  end
86
80
 
87
81
  def write(*msg, timeout: nil, exception: nil)
88
82
  raise(NotConnectedError) if closed?
89
83
  deadline = create_deadline(timeout, configuration.write_timeout)
90
- return @socket.write(*msg) unless deadline.valid?
84
+ return stem_errors { @socket.write(*msg) } unless deadline.valid?
91
85
  exception ||= configuration.write_timeout_error
92
- msg.sum do |chunk|
93
- @socket.write_with_deadline(chunk.b, deadline, exception)
86
+ stem_errors(exception) do
87
+ msg.sum do |chunk|
88
+ @socket.write_with_deadline(chunk.b, deadline, exception)
89
+ end
94
90
  end
95
91
  end
96
92
 
97
93
  def flush
98
- @socket&.flush
94
+ stem_errors { @socket&.flush }
99
95
  self
100
96
  end
101
97
 
@@ -108,8 +104,33 @@ class TCPClient
108
104
  def create_socket(timeout, exception)
109
105
  deadline = create_deadline(timeout, configuration.connect_timeout)
110
106
  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)
107
+ stem_errors(exception) do
108
+ @socket = TCPSocket.new(address, configuration, deadline, exception)
109
+ return @socket unless configuration.ssl?
110
+ SSLSocket.new(@socket, address, configuration, deadline, exception)
111
+ end
114
112
  end
113
+
114
+ def stem_errors(except = nil)
115
+ yield
116
+ rescue *NETWORK_ERRORS => e
117
+ raise unless configuration.normalize_network_errors
118
+ (except && e.is_a?(except)) ? raise : raise(NetworkError, e)
119
+ end
120
+
121
+ NETWORK_ERRORS =
122
+ [
123
+ Errno::EADDRNOTAVAIL,
124
+ Errno::ECONNABORTED,
125
+ Errno::ECONNREFUSED,
126
+ Errno::ECONNRESET,
127
+ Errno::EHOSTUNREACH,
128
+ Errno::EINVAL,
129
+ Errno::ENETUNREACH,
130
+ Errno::EPIPE,
131
+ IOError,
132
+ SocketError
133
+ ].tap do |errors|
134
+ errors << ::OpenSSL::SSL::SSLError if defined?(::OpenSSL::SSL::SSLError)
135
+ end.freeze
115
136
  end
data/rakefile.rb CHANGED
@@ -1,8 +1,8 @@
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
6
 
7
7
  $stdout.sync = $stderr.sync = true
8
8
 
@@ -10,7 +10,4 @@ CLOBBER << 'prj'
10
10
 
11
11
  task(:default) { exec('rake --tasks') }
12
12
 
13
- Rake::TestTask.new(:test) do |task|
14
- task.pattern = 'test/**/*_test.rb'
15
- task.warning = task.verbose = true
16
- end
13
+ RSpec::Core::RakeTask.new { |task| task.ruby_opts = %w[-w] }
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
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helper'
4
+
5
+ RSpec.describe TCPClient::Address do
6
+ describe '.new' do
7
+ context 'when called with an Integer parameter' do
8
+ subject(:address) { TCPClient::Address.new(42) }
9
+
10
+ it 'points to the given port on localhost' do
11
+ expect(address.hostname).to eq 'localhost'
12
+ expect(address.to_s).to eq 'localhost:42'
13
+ expect(address.addrinfo.ip_port).to be 42
14
+ end
15
+
16
+ it 'uses IPv6' do
17
+ expect(address.addrinfo.ip?).to be true
18
+ expect(address.addrinfo.ipv6?).to be true
19
+ expect(address.addrinfo.ipv4?).to be false
20
+ end
21
+ end
22
+
23
+ context 'when called with an Addrinfo' do
24
+ subject(:address) { TCPClient::Address.new(addrinfo) }
25
+ let(:addrinfo) { Addrinfo.tcp('::1', 42) }
26
+
27
+ it 'uses the given Addrinfo' do
28
+ expect(address.addrinfo).to eq addrinfo
29
+ end
30
+
31
+ it 'points to the given host and port' do
32
+ expect(address.hostname).to eq addrinfo.getnameinfo[0]
33
+ expect(address.addrinfo.ip_port).to be 42
34
+ end
35
+
36
+ it 'uses IPv6' do
37
+ expect(address.addrinfo.ip?).to be true
38
+ expect(address.addrinfo.ipv6?).to be true
39
+ expect(address.addrinfo.ipv4?).to be false
40
+ end
41
+ end
42
+
43
+ context 'when called with a String' do
44
+ context 'when a host name and port is provided' do
45
+ subject(:address) { TCPClient::Address.new('localhost:42') }
46
+
47
+ it 'points to the given host and port' do
48
+ expect(address.hostname).to eq 'localhost'
49
+ expect(address.to_s).to eq 'localhost:42'
50
+ expect(address.addrinfo.ip_port).to be 42
51
+ end
52
+
53
+ it 'uses IPv6' do
54
+ expect(address.addrinfo.ip?).to be true
55
+ expect(address.addrinfo.ipv6?).to be true
56
+ expect(address.addrinfo.ipv4?).to be false
57
+ end
58
+ end
59
+
60
+ context 'when only a port is provided' do
61
+ subject(:address) { TCPClient::Address.new(':21') }
62
+
63
+ it 'points to the given port on localhost' do
64
+ expect(address.hostname).to eq ''
65
+ expect(address.to_s).to eq ':21'
66
+ expect(address.addrinfo.ip_port).to be 21
67
+ end
68
+
69
+ it 'uses IPv4' do
70
+ expect(address.addrinfo.ip?).to be true
71
+ expect(address.addrinfo.ipv6?).to be false
72
+ expect(address.addrinfo.ipv4?).to be true
73
+ end
74
+ end
75
+
76
+ context 'when an IPv6 address is provided' do
77
+ subject(:address) { TCPClient::Address.new('[::1]:42') }
78
+
79
+ it 'points to the given port on localhost' do
80
+ expect(address.hostname).to eq '::1'
81
+ expect(address.to_s).to eq '[::1]:42'
82
+ expect(address.addrinfo.ip_port).to be 42
83
+ end
84
+
85
+ it 'uses IPv6' do
86
+ expect(address.addrinfo.ip?).to be true
87
+ expect(address.addrinfo.ipv6?).to be true
88
+ expect(address.addrinfo.ipv4?).to be false
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ describe '#to_hash' do
95
+ subject(:address) { TCPClient::Address.new('localhost:42') }
96
+
97
+ it 'returns itself as an Hash' do
98
+ expect(address.to_hash).to eq(host: 'localhost', port: 42)
99
+ end
100
+ end
101
+
102
+ describe '#to_h' do
103
+ subject(:address) { TCPClient::Address.new('localhost:42') }
104
+
105
+ it 'returns itself as an Hash' do
106
+ expect(address.to_h).to eq(host: 'localhost', port: 42)
107
+ end
108
+
109
+ it 'allows to specify the keys the result should contain' do
110
+ expect(address.to_h(:port)).to eq(port: 42)
111
+ expect(address.to_h(:host)).to eq(host: 'localhost')
112
+ end
113
+ end
114
+
115
+ describe 'comparison' do
116
+ context 'comparing two equal instances' do
117
+ let(:address_a) { TCPClient::Address.new('localhost:42') }
118
+ let(:address_b) { TCPClient::Address.new('localhost:42') }
119
+
120
+ it 'compares to equal' do
121
+ expect(address_a).to eq address_b
122
+ end
123
+
124
+ context 'using the == opperator' do
125
+ it 'compares to equal' do
126
+ expect(address_a == address_b).to be true
127
+ end
128
+ end
129
+
130
+ context 'using the === opperator' do
131
+ it 'compares to equal' do
132
+ expect(address_a === address_b).to be true
133
+ end
134
+ end
135
+ end
136
+
137
+ context 'comparing two non-equal instances' do
138
+ let(:address_a) { TCPClient::Address.new('localhost:42') }
139
+ let(:address_b) { TCPClient::Address.new('localhost:21') }
140
+
141
+ it 'compares not to equal' do
142
+ expect(address_a).not_to eq address_b
143
+ end
144
+
145
+ context 'using the == opperator' do
146
+ it 'compares not to equal' do
147
+ expect(address_a == address_b).to be false
148
+ end
149
+ end
150
+
151
+ context 'using the === opperator' do
152
+ it 'compares not to equal' do
153
+ expect(address_a === address_b).to be false
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end