socketry 0.1.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 +7 -0
- data/.gitignore +9 -0
- data/.rspec +5 -0
- data/.rubocop.yml +56 -0
- data/.ruby-version +1 -0
- data/.travis.yml +16 -0
- data/CHANGES.md +3 -0
- data/Gemfile +20 -0
- data/Guardfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +55 -0
- data/Rakefile +10 -0
- data/lib/socketry.rb +24 -0
- data/lib/socketry/exceptions.rb +31 -0
- data/lib/socketry/resolver/resolv.rb +65 -0
- data/lib/socketry/resolver/system.rb +48 -0
- data/lib/socketry/ssl/server.rb +62 -0
- data/lib/socketry/ssl/socket.rb +145 -0
- data/lib/socketry/tcp/server.rb +83 -0
- data/lib/socketry/tcp/socket.rb +279 -0
- data/lib/socketry/timeout.rb +74 -0
- data/lib/socketry/udp/socket.rb +130 -0
- data/lib/socketry/version.rb +5 -0
- data/socketry.gemspec +29 -0
- metadata +97 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: dae535663b1bc19486c7766d86d1444cf097fb81
|
4
|
+
data.tar.gz: 31212e71094435898cd1f2937bece83f9f863708
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5e4bedb944e9683e08e6027edc585c09383c9f962eec07375a70fff0aaac3fdeedf5020ec12beaa56213de0bb96d1b1a3d66fbd0cd5655544e22b96530f52836
|
7
|
+
data.tar.gz: b169b991a93b8667ad3f532889751e05aa5e25a98825452da04ddcbcfc5652a8fb01218f0a48212d111e02316f677156d72190ba64e6a74b0bfbe1325ed251b2
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
AllCops:
|
2
|
+
DisplayCopNames: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# Style
|
6
|
+
#
|
7
|
+
|
8
|
+
LineLength:
|
9
|
+
Max: 128
|
10
|
+
|
11
|
+
Style/AccessorMethodName:
|
12
|
+
Enabled: false
|
13
|
+
|
14
|
+
Style/ConditionalAssignment:
|
15
|
+
Enabled: false
|
16
|
+
|
17
|
+
Style/RescueModifier:
|
18
|
+
Enabled: false
|
19
|
+
|
20
|
+
Style/SpaceBeforeFirstArg:
|
21
|
+
Enabled: false
|
22
|
+
|
23
|
+
Style/StringLiterals:
|
24
|
+
EnforcedStyle: double_quotes
|
25
|
+
|
26
|
+
#
|
27
|
+
# Metrics
|
28
|
+
#
|
29
|
+
|
30
|
+
Metrics/AbcSize:
|
31
|
+
Max: 50
|
32
|
+
|
33
|
+
Metrics/ClassLength:
|
34
|
+
Max: 200
|
35
|
+
|
36
|
+
Metrics/CyclomaticComplexity:
|
37
|
+
Max: 10
|
38
|
+
|
39
|
+
Metrics/MethodLength:
|
40
|
+
Max: 50
|
41
|
+
|
42
|
+
Metrics/ParameterLists:
|
43
|
+
Enabled: false
|
44
|
+
|
45
|
+
Metrics/PerceivedComplexity:
|
46
|
+
Max: 10
|
47
|
+
|
48
|
+
#
|
49
|
+
# Lint
|
50
|
+
#
|
51
|
+
|
52
|
+
Lint/HandleExceptions:
|
53
|
+
Enabled: false
|
54
|
+
|
55
|
+
Lint/ShadowedException:
|
56
|
+
Enabled: false
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.3.1
|
data/.travis.yml
ADDED
data/CHANGES.md
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source "https://rubygems.org"
|
4
|
+
|
5
|
+
gemspec
|
6
|
+
|
7
|
+
group :development do
|
8
|
+
gem "guard-rspec", require: false
|
9
|
+
gem "pry", require: false
|
10
|
+
end
|
11
|
+
|
12
|
+
group :test do
|
13
|
+
gem "rspec", "~> 3", require: false
|
14
|
+
gem "rubocop", "0.42.0", require: false
|
15
|
+
gem "coveralls", require: false
|
16
|
+
end
|
17
|
+
|
18
|
+
group :development, :test do
|
19
|
+
gem "rake"
|
20
|
+
end
|
data/Guardfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2015-2016 Tony Arcieri, Zachary Anker
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
# Socketry [![Gem Version][gem-image]][gem-link] [![Build Status][build-image]][build-link] [![Code Climate][codeclimate-image]][codeclimate-link] [![Coverage Status][coverage-image]][coverage-link] [![MIT licensed][license-image]][license-link]
|
2
|
+
|
3
|
+
[gem-image]: https://badge.fury.io/rb/socketry.svg
|
4
|
+
[gem-link]: https://rubygems.org/gems/socketry
|
5
|
+
[build-image]: https://secure.travis-ci.org/socketry/socketry.svg?branch=master
|
6
|
+
[build-link]: https://travis-ci.org/socketry/socketry
|
7
|
+
[codeclimate-image]: https://codeclimate.com/github/socketry/socketry.svg?branch=master
|
8
|
+
[codeclimate-link]: https://codeclimate.com/github/socketry/socketry
|
9
|
+
[coverage-image]: https://coveralls.io/repos/github/socketry/socketry/badge.svg?branch=master
|
10
|
+
[coverage-link]: https://coveralls.io/github/socketry/socketry?branch=master
|
11
|
+
[license-image]: https://img.shields.io/badge/license-MIT-blue.svg
|
12
|
+
[license-link]: https://github.com/socketry/socketry/blob/master/LICENSE.txt
|
13
|
+
|
14
|
+
High-level wrappers for Ruby sockets with advanced thread-safe timeout support.
|
15
|
+
|
16
|
+
**Does not require Celluloid!** Socketry provides sockets with thread-safe
|
17
|
+
timeout support that can be used with any multithreaded Ruby app. That said,
|
18
|
+
Socketry can also be used to provide asynchronous I/O with [Celluloid::IO].
|
19
|
+
|
20
|
+
[Celluloid::IO]: https://github.com/celluloid/celluloid-io
|
21
|
+
|
22
|
+
## Installation
|
23
|
+
|
24
|
+
Add this line to your application's Gemfile:
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
gem 'socketry'
|
28
|
+
```
|
29
|
+
|
30
|
+
And then execute:
|
31
|
+
|
32
|
+
$ bundle
|
33
|
+
|
34
|
+
Or install it yourself as:
|
35
|
+
|
36
|
+
$ gem install socketry
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
TODO: Coming soon!
|
41
|
+
|
42
|
+
## Contributing
|
43
|
+
|
44
|
+
* Fork this repository on github
|
45
|
+
* Make your changes and send us a pull request
|
46
|
+
* If we like them we'll merge them
|
47
|
+
* If we've accepted a patch, feel free to ask for commit access
|
48
|
+
|
49
|
+
## License
|
50
|
+
|
51
|
+
Copyright (c) 2016 Tony Arcieri
|
52
|
+
|
53
|
+
Distributed under the MIT License. See
|
54
|
+
[LICENSE.txt](https://github.com/socketry/socketry/blob/master/LICENSE.txt)
|
55
|
+
for further details.
|
data/Rakefile
ADDED
data/lib/socketry.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Ruby stdlib dependencies
|
4
|
+
require "io/wait"
|
5
|
+
require "ipaddr"
|
6
|
+
require "socket"
|
7
|
+
require "openssl"
|
8
|
+
|
9
|
+
# External gems
|
10
|
+
require "hitimes"
|
11
|
+
|
12
|
+
# Socketry codebase
|
13
|
+
require "socketry/version"
|
14
|
+
|
15
|
+
require "socketry/exceptions"
|
16
|
+
require "socketry/resolver/resolv"
|
17
|
+
require "socketry/resolver/system"
|
18
|
+
require "socketry/timeout"
|
19
|
+
|
20
|
+
require "socketry/tcp/server"
|
21
|
+
require "socketry/tcp/socket"
|
22
|
+
require "socketry/ssl/server"
|
23
|
+
require "socketry/ssl/socket"
|
24
|
+
require "socketry/udp/socket"
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
# Generic catch all for all Socketry errors
|
5
|
+
Error = Class.new(::IOError)
|
6
|
+
|
7
|
+
# Invalid address
|
8
|
+
AddressError = Class.new(Socketry::Error)
|
9
|
+
|
10
|
+
# Timeouts performing an I/O operation
|
11
|
+
TimeoutError = Class.new(Socketry::Error)
|
12
|
+
|
13
|
+
# Cannot perform operation in current state
|
14
|
+
StateError = Class.new(Socketry::Error)
|
15
|
+
|
16
|
+
# Internal consistency error within the library
|
17
|
+
InternalError = Class.new(Socketry::Error)
|
18
|
+
|
19
|
+
module Resolver
|
20
|
+
# DNS resolution errors
|
21
|
+
Error = Class.new(Socketry::AddressError)
|
22
|
+
end
|
23
|
+
|
24
|
+
module SSL
|
25
|
+
# Errors related to SSL
|
26
|
+
Error = Class.new(Socketry::Error)
|
27
|
+
|
28
|
+
# Hostname verification error
|
29
|
+
HostnameError = Class.new(Socketry::SSL::Error)
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
module Resolver
|
5
|
+
# Pure Ruby DNS resolver provided by the standard library
|
6
|
+
class Resolv
|
7
|
+
# Resolve a hostname by creating and discaring a Socketry::Resolver::Resolv
|
8
|
+
# instance. For better performance, create and reuse an instance.
|
9
|
+
def self.resolve(hostname, **options)
|
10
|
+
resolver = new
|
11
|
+
begin
|
12
|
+
resolver.resolve(hostname, **options)
|
13
|
+
ensure
|
14
|
+
resolver.close
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Create a new instance of Socketry::Resolver::Resolv.
|
19
|
+
#
|
20
|
+
# Arguments are passed directly to Resolv::DNS. See the Ruby documentation
|
21
|
+
# for more information:
|
22
|
+
#
|
23
|
+
# https://ruby-doc.org/stdlib-2.3.1/libdoc/resolv/rdoc/Resolv/DNS.html
|
24
|
+
#
|
25
|
+
def initialize(*args)
|
26
|
+
@hosts = ::Resolv::Hosts.new
|
27
|
+
@resolver = ::Resolv::DNS.new(*args)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Resolve a domain name using IPSocket.getaddress. This uses getaddrinfo(3)
|
31
|
+
# on POSIX operating systems.
|
32
|
+
#
|
33
|
+
# @param hostname [String] name of the host whose IP address we'd like to obtain
|
34
|
+
# @return [IPAddr] resolved IP address
|
35
|
+
# @raise [Socketry::Resolver::Error] an error occurred resolving the domain name
|
36
|
+
# @raise [Socketry::TimeoutError] a timeout occured before the name could be resolved
|
37
|
+
# @raise [Socketry::AddressError] the name was resolved to an unsupported address
|
38
|
+
def resolve(hostname, timeout: nil)
|
39
|
+
raise TypeError, "expected String, got #{hostname.class}" unless hostname.is_a?(String)
|
40
|
+
return IPAddr.new(@hosts.getaddress(hostname).sub(/%.*$/, ""))
|
41
|
+
rescue ::Resolv::ResolvError
|
42
|
+
case timeout
|
43
|
+
when Integer, Float
|
44
|
+
@resolver.timeouts = timeout
|
45
|
+
when NilClass
|
46
|
+
# no timeout
|
47
|
+
else raise TypeError, "expected Numeric, got #{timeout.class}"
|
48
|
+
end
|
49
|
+
|
50
|
+
begin
|
51
|
+
IPAddr.new(@resolver.getaddress(hostname).to_s)
|
52
|
+
rescue ::Resolv::ResolvError => ex
|
53
|
+
raise Socketry::Resolver::Error, ex.message, ex.backtrace
|
54
|
+
rescue ::Resolv::ResolvTimeout => ex
|
55
|
+
raise Socketry::TimeoutError, ex.message, ex.backtrace
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Close the resolver
|
60
|
+
def close
|
61
|
+
@resolver.close
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "timeout"
|
4
|
+
|
5
|
+
module Socketry
|
6
|
+
module Resolver
|
7
|
+
# System DNS resolver backed by the POSIX getaddrinfo(3) function
|
8
|
+
module System
|
9
|
+
module_function
|
10
|
+
|
11
|
+
# Resolve a domain name using IPSocket.getaddress. This uses getaddrinfo(3)
|
12
|
+
# on POSIX operating systems.
|
13
|
+
#
|
14
|
+
# @param hostname [String] name of the host whose IP address we'd like to obtain
|
15
|
+
# @return [IPAddr] resolved IP address
|
16
|
+
# @raise [Socketry::Resolver::Error] an error occurred resolving the domain name
|
17
|
+
# @raise [Socketry::TimeoutError] a timeout occured before the name could be resolved
|
18
|
+
# @raise [Socketry::AddressError] the name was resolved to an unsupported address
|
19
|
+
def resolve(hostname, timeout: nil)
|
20
|
+
raise TypeError, "expected String, got #{hostname.class}" unless hostname.is_a?(String)
|
21
|
+
|
22
|
+
begin
|
23
|
+
case timeout
|
24
|
+
when Integer, Float
|
25
|
+
# NOTE: ::Timeout is not thread safe. For thread safety, use Socketry::Resolver::Resolv
|
26
|
+
result = ::Timeout.timeout(timeout) { IPSocket.getaddress(hostname) }
|
27
|
+
when NilClass
|
28
|
+
result = IPSocket.getaddress(hostname)
|
29
|
+
else raise TypeError, "expected Numeric, got #{timeout.class}"
|
30
|
+
end
|
31
|
+
rescue ::SocketError => ex
|
32
|
+
raise Socketry::Resolver::Error, ex.message, ex.backtrace
|
33
|
+
rescue ::Timeout::Error => ex
|
34
|
+
raise Socketry::TimeoutError, ex.message, ex.backtrace
|
35
|
+
end
|
36
|
+
|
37
|
+
begin
|
38
|
+
IPAddr.new(result)
|
39
|
+
rescue IPAddr::InvalidAddressError => ex
|
40
|
+
raise Socketry::AddressError, ex.message, ex.backtrace
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Use Socketry::Resolver::System as the default resolver
|
46
|
+
DEFAULT_RESOLVER = System
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
# Secure Sockets Layer (a.k.a. Transport Layer Security, or TLS)
|
5
|
+
module SSL
|
6
|
+
# SSL Server
|
7
|
+
class Server < Socketry::TCP::Server
|
8
|
+
# Create a new SSL server
|
9
|
+
#
|
10
|
+
# @return [Socketry::SSL::Server]
|
11
|
+
def initialize(
|
12
|
+
hostname_or_port,
|
13
|
+
port = nil,
|
14
|
+
ssl_socket_class: OpenSSL::SSL::SSLSocket,
|
15
|
+
ssl_params: nil,
|
16
|
+
**args
|
17
|
+
)
|
18
|
+
raise TypeError, "expected Hash, got #{ssl_params.class}" if ssl_params && !ssl_params.is_a?(Hash)
|
19
|
+
|
20
|
+
@ssl_socket_class = ssl_socket_class
|
21
|
+
@ssl_context = OpenSSL::SSL::SSLContext.new
|
22
|
+
@ssl_context.set_params(ssl_params) if ssl_params
|
23
|
+
@ssl_context.freeze
|
24
|
+
|
25
|
+
super(hostname_or_port, port, **args)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Accept a connection to the server
|
29
|
+
#
|
30
|
+
# Note that this method also performs an SSL handshake and will therefore
|
31
|
+
# block other sockets which are ready to be accepted.
|
32
|
+
#
|
33
|
+
# Multithreaded servers should invoke this method after spawning a thread
|
34
|
+
# to ensure a slow/malicious connection can't cause a denial-of-service
|
35
|
+
# attack against the server.
|
36
|
+
#
|
37
|
+
# @param timeout [Numeric, NilClass] seconds to wait before aborting the accept
|
38
|
+
# @return [Socketry::SSL::Socket]
|
39
|
+
def accept(timeout: nil, **args)
|
40
|
+
ruby_socket = super(timeout: timeout, **args).to_io
|
41
|
+
ssl_socket = @ssl_socket_class.new(ruby_socket, @ssl_context)
|
42
|
+
|
43
|
+
begin
|
44
|
+
ssl_socket.accept_nonblock
|
45
|
+
rescue IO::WaitReadable
|
46
|
+
retry if IO.select([ruby_socket], nil, nil, timeout)
|
47
|
+
raise Socketry::TimeoutError, "failed to complete handshake after #{timeout} seconds"
|
48
|
+
rescue IO::WaitWritable
|
49
|
+
retry if IO.select(nil, [ruby_socket], nil, timeout)
|
50
|
+
raise Socketry::TimeoutError, "failed to complete handshake after #{timeout} seconds"
|
51
|
+
end
|
52
|
+
|
53
|
+
Socketry::SSL::Socket.new(
|
54
|
+
read_timeout: @read_timeout,
|
55
|
+
write_timeout: @write_timeout,
|
56
|
+
resolver: @resolver,
|
57
|
+
socket_class: @socket_class
|
58
|
+
).from_socket(ruby_socket)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
# Secure Sockets Layer (a.k.a. Transport Layer Security, or TLS)
|
5
|
+
module SSL
|
6
|
+
# SSL Sockets
|
7
|
+
class Socket < Socketry::TCP::Socket
|
8
|
+
# Create an unconnected Socketry::SSL::Socket
|
9
|
+
#
|
10
|
+
# @param read_timeout [Numeric] Seconds to wait before an uncompleted read errors
|
11
|
+
# @param write_timeout [Numeric] Seconds to wait before an uncompleted write errors
|
12
|
+
# @param timer [Object] A timekeeping object to use for measuring timeouts
|
13
|
+
# @param resolver [Object] A resolver object to use for resolving DNS names
|
14
|
+
# @param socket_class [Object] Underlying socket class which implements I/O ops
|
15
|
+
# @return [Socketry::SSL::Socket]
|
16
|
+
def initialize(ssl_socket_class: OpenSSL::SSL::SSLSocket, ssl_params: nil, **args)
|
17
|
+
raise TypeError, "expected Hash, got #{ssl_params.class}" if ssl_params && !ssl_params.is_a?(Hash)
|
18
|
+
|
19
|
+
@ssl_socket_class = ssl_socket_class
|
20
|
+
@ssl_context = OpenSSL::SSL::SSLContext.new
|
21
|
+
@ssl_context.set_params(ssl_params) if ssl_params
|
22
|
+
@ssl_context.freeze
|
23
|
+
@ssl_socket = nil
|
24
|
+
|
25
|
+
super(**args)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Make an SSL connection to a remote host
|
29
|
+
#
|
30
|
+
# @param remote_addr [String] DNS name or IP address of the host to connect to
|
31
|
+
# @param remote_port [Fixnum] TCP port to connect to
|
32
|
+
# @param local_addr [String] DNS name or IP address to bind to locally
|
33
|
+
# @param local_port [Fixnum] Local TCP port to bind to
|
34
|
+
# @param timeout [Numeric] Number of seconds to wait before aborting connect
|
35
|
+
# @param socket_class [Class] Custom low-level socket class
|
36
|
+
# @raise [Socketry::AddressError] an invalid address was given
|
37
|
+
# @raise [Socketry::TimeoutError] connect operation timed out
|
38
|
+
# @raise [Socketry::SSL::Error] an error occurred negotiating an SSL connection
|
39
|
+
# @return [self]
|
40
|
+
def connect(
|
41
|
+
remote_addr,
|
42
|
+
remote_port,
|
43
|
+
local_addr: nil,
|
44
|
+
local_port: nil,
|
45
|
+
timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:connect],
|
46
|
+
verify_hostname: true
|
47
|
+
)
|
48
|
+
super(remote_addr, remote_port, local_addr: local_addr, local_port: local_port, timeout: timeout)
|
49
|
+
|
50
|
+
@ssl_socket = OpenSSL::SSL::SSLSocket.new(@socket, @ssl_context)
|
51
|
+
@ssl_socket.hostname = remote_addr
|
52
|
+
|
53
|
+
begin
|
54
|
+
@ssl_socket.connect_nonblock
|
55
|
+
rescue IO::WaitReadable
|
56
|
+
retry if @socket.wait_readable(timeout)
|
57
|
+
raise Socketry::TimeoutError, "connection to #{remote_addr}:#{remote_port} timed out"
|
58
|
+
rescue IO::WaitWritable
|
59
|
+
retry if @socket.wait_writable(timeout)
|
60
|
+
raise Socketry::TimeoutError, "connection to #{remote_addr}:#{remote_port} timed out"
|
61
|
+
rescue OpenSSL::SSL::SSLError => ex
|
62
|
+
raise Socketry::SSL::Error, ex.message, ex.backtrace
|
63
|
+
end
|
64
|
+
|
65
|
+
begin
|
66
|
+
@ssl_socket.post_connection_check(remote_addr) if verify_hostname
|
67
|
+
rescue OpenSSL::SSL::SSLError => ex
|
68
|
+
raise Socketry::SSL::HostnameError, ex.message, ex.backtrace
|
69
|
+
end
|
70
|
+
|
71
|
+
self
|
72
|
+
rescue => ex
|
73
|
+
@socket.close rescue nil
|
74
|
+
@socket = nil
|
75
|
+
@ssl_socket.close rescue nil
|
76
|
+
@ssl_socket = nil
|
77
|
+
raise ex
|
78
|
+
end
|
79
|
+
|
80
|
+
# Wrap a Ruby OpenSSL::SSL::SSLSocket (or other low-level SSL socket)
|
81
|
+
#
|
82
|
+
# @param socket [::Socket] (or specified socket_class) low-level socket to wrap
|
83
|
+
# @param ssl_socket [OpenSSL::SSL::SSLSocket] SSL socket class associated with this socket
|
84
|
+
# @return [self]
|
85
|
+
def from_socket(socket, ssl_socket)
|
86
|
+
raise TypeError, "expected #{@socket_class}, got #{socket.class}" unless socket.is_a?(@socket_class)
|
87
|
+
raise TypeError, "expected #{@ssl_socket_class}, got #{ssl_socket.class}" unless ssl_socket.is_a?(@ssl_socket_class)
|
88
|
+
raise StateError, "already connected" if @socket && @socket != socket
|
89
|
+
|
90
|
+
@socket = socket
|
91
|
+
@ssl_socket = ssl_socket
|
92
|
+
@ssl_socket.sync_close = true
|
93
|
+
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
# Perform a non-blocking read operation
|
98
|
+
#
|
99
|
+
# @param size [Fixnum] number of bytes to attempt to read
|
100
|
+
# @param outbuf [String, NilClass] an optional buffer into which data should be read
|
101
|
+
# @raise [Socketry::Error] an I/O operation failed
|
102
|
+
# @return [String, :wait_readable] data read, or :wait_readable if operation would block
|
103
|
+
def read_nonblock(size)
|
104
|
+
ensure_connected
|
105
|
+
@ssl_socket.read_nonblock(size, exception: false)
|
106
|
+
# Some buggy Rubies continue to raise exceptions in these cases
|
107
|
+
rescue IO::WaitReadable
|
108
|
+
:wait_readable
|
109
|
+
# Due to SSL, we may need to write to complete a read (e.g. renegotiation)
|
110
|
+
rescue IO::WaitWritable
|
111
|
+
:wait_writable
|
112
|
+
rescue => ex
|
113
|
+
# TODO: more specific exceptions
|
114
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
115
|
+
end
|
116
|
+
|
117
|
+
# Perform a non-blocking write operation
|
118
|
+
#
|
119
|
+
# @param data [String] number of bytes to attempt to read
|
120
|
+
# @raise [Socketry::Error] an I/O operation failed
|
121
|
+
# @return [Fixnum, :wait_writable] number of bytes written, or :wait_writable if op would block
|
122
|
+
def write_nonblock(data)
|
123
|
+
ensure_connected
|
124
|
+
@ssl_socket.write_nonblock(data, exception: false)
|
125
|
+
# Some buggy Rubies continue to raise this exception
|
126
|
+
rescue IO::WaitWriteable
|
127
|
+
:wait_writable
|
128
|
+
# Due to SSL, we may need to write to complete a read (e.g. renegotiation)
|
129
|
+
rescue IO::WaitReadable
|
130
|
+
:wait_readable
|
131
|
+
rescue => ex
|
132
|
+
# TODO: more specific exceptions
|
133
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
134
|
+
end
|
135
|
+
|
136
|
+
# Close the socket
|
137
|
+
#
|
138
|
+
# @return [true, false] true if the socket was open, false if closed
|
139
|
+
def close
|
140
|
+
@ssl_socket.close
|
141
|
+
super
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
# Transmission Control Protocol
|
5
|
+
module TCP
|
6
|
+
# Transmission Control Protocol servers: Accept connections from the network
|
7
|
+
class Server
|
8
|
+
include Socketry::Timeout
|
9
|
+
alias uptime lifetime
|
10
|
+
|
11
|
+
attr_reader :read_timeout, :write_timeout, :resolver, :socket_class
|
12
|
+
|
13
|
+
# Create a new TCP server, yielding the server socket and auto-closing it
|
14
|
+
def self.open(hostname_or_port, port = nil, **args)
|
15
|
+
server = new(hostname_or_port, port, **args)
|
16
|
+
result = yield server
|
17
|
+
server.close
|
18
|
+
result
|
19
|
+
end
|
20
|
+
|
21
|
+
# Create a new TCP server
|
22
|
+
#
|
23
|
+
# @return [Socketry::TCP::Server]
|
24
|
+
def initialize(
|
25
|
+
hostname_or_port,
|
26
|
+
port = nil,
|
27
|
+
read_timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:read],
|
28
|
+
write_timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:write],
|
29
|
+
timer: Socketry::Timeout::DEFAULT_TIMER.new,
|
30
|
+
resolver: Socketry::Resolver::DEFAULT_RESOLVER,
|
31
|
+
server_class: ::TCPServer,
|
32
|
+
socket_class: ::TCPSocket
|
33
|
+
)
|
34
|
+
@read_timeout = read_timeout
|
35
|
+
@write_timeout = write_timeout
|
36
|
+
@resolver = resolver
|
37
|
+
@socket_class = socket_class
|
38
|
+
|
39
|
+
if port
|
40
|
+
@server = server_class.new(@resolver.resolve(hostname_or_port).to_s, port)
|
41
|
+
else
|
42
|
+
@server = server_class.new(hostname_or_port)
|
43
|
+
end
|
44
|
+
|
45
|
+
start_timer(timer)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Accept a connection to the server
|
49
|
+
#
|
50
|
+
# @param timeout [Numeric, NilClass] seconds to wait before aborting the accept
|
51
|
+
# @return [Socketry::TCP::Socket]
|
52
|
+
def accept(timeout: nil)
|
53
|
+
set_timeout(timeout)
|
54
|
+
|
55
|
+
begin
|
56
|
+
# Note: `exception: false` for TCPServer#accept_nonblock is only supported in Ruby 2.3+
|
57
|
+
ruby_socket = @server.accept_nonblock
|
58
|
+
rescue IO::WaitReadable, Errno::EAGAIN
|
59
|
+
# Ruby 2.2 has trouble using io/wait here
|
60
|
+
retry if IO.select([@server], nil, nil, time_remaining(timeout))
|
61
|
+
raise Socketry::TimeoutError, "no connection received after #{timeout} seconds"
|
62
|
+
end
|
63
|
+
|
64
|
+
Socketry::TCP::Socket.new(
|
65
|
+
read_timeout: @read_timeout,
|
66
|
+
write_timeout: @write_timeout,
|
67
|
+
resolver: @resolver,
|
68
|
+
socket_class: @socket_class
|
69
|
+
).from_socket(ruby_socket)
|
70
|
+
ensure
|
71
|
+
clear_timeout(timeout)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Close the server
|
75
|
+
def close
|
76
|
+
return false unless @server
|
77
|
+
@server.close rescue nil
|
78
|
+
@server = nil
|
79
|
+
true
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,279 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
# Transmission Control Protocol
|
5
|
+
module TCP
|
6
|
+
# Transmission Control Protocol sockets: Provide stream-like semantics
|
7
|
+
class Socket
|
8
|
+
include Socketry::Timeout
|
9
|
+
|
10
|
+
attr_reader :remote_addr, :remote_port, :local_addr, :local_port
|
11
|
+
attr_reader :read_timeout, :write_timeout, :resolver, :socket_class
|
12
|
+
|
13
|
+
# Create a Socketry::TCP::Socket with the default options, then connect
|
14
|
+
# to the given host.
|
15
|
+
#
|
16
|
+
# @param remote_addr [String] DNS name or IP address of the host to connect to
|
17
|
+
# @param remote_port [Fixnum] TCP port to connect to
|
18
|
+
# @return [Socketry::TCP::Socket]
|
19
|
+
def self.connect(remote_addr, remote_port, **args)
|
20
|
+
new.connect(remote_addr, remote_port, **args)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Create an unconnected Socketry::TCP::Socket
|
24
|
+
#
|
25
|
+
# @param read_timeout [Numeric] Seconds to wait before an uncompleted read errors
|
26
|
+
# @param write_timeout [Numeric] Seconds to wait before an uncompleted write errors
|
27
|
+
# @param timer [Object] A timekeeping object to use for measuring timeouts
|
28
|
+
# @param resolver [Object] A resolver object to use for resolving DNS names
|
29
|
+
# @param socket_class [Object] Underlying socket class which implements I/O ops
|
30
|
+
# @return [Socketry::TCP::Socket]
|
31
|
+
def initialize(
|
32
|
+
read_timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:read],
|
33
|
+
write_timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:write],
|
34
|
+
timer: Socketry::Timeout::DEFAULT_TIMER.new,
|
35
|
+
resolver: Socketry::Resolver::DEFAULT_RESOLVER,
|
36
|
+
socket_class: ::Socket
|
37
|
+
)
|
38
|
+
@read_timeout = read_timeout
|
39
|
+
@write_timeout = write_timeout
|
40
|
+
|
41
|
+
@socket_class = socket_class
|
42
|
+
@resolver = resolver
|
43
|
+
|
44
|
+
@family = nil
|
45
|
+
@socket = nil
|
46
|
+
|
47
|
+
@remote_addr = nil
|
48
|
+
@remote_port = nil
|
49
|
+
@local_addr = nil
|
50
|
+
@local_port = nil
|
51
|
+
|
52
|
+
start_timer(timer)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Connect to a remote host
|
56
|
+
#
|
57
|
+
# @param remote_addr [String] DNS name or IP address of the host to connect to
|
58
|
+
# @param remote_port [Fixnum] TCP port to connect to
|
59
|
+
# @param local_addr [String] DNS name or IP address to bind to locally
|
60
|
+
# @param local_port [Fixnum] Local TCP port to bind to
|
61
|
+
# @param timeout [Numeric] Number of seconds to wait before aborting connect
|
62
|
+
# @param socket_class [Class] Custom low-level socket class
|
63
|
+
# @raise [Socketry::AddressError] an invalid address was given
|
64
|
+
# @raise [Socketry::TimeoutError] connect operation timed out
|
65
|
+
# @return [self]
|
66
|
+
def connect(
|
67
|
+
remote_addr,
|
68
|
+
remote_port,
|
69
|
+
local_addr: nil,
|
70
|
+
local_port: nil,
|
71
|
+
timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:connect]
|
72
|
+
)
|
73
|
+
ensure_disconnected
|
74
|
+
|
75
|
+
@remote_addr = remote_addr
|
76
|
+
@remote_port = remote_port
|
77
|
+
@local_addr = local_addr
|
78
|
+
@local_port = local_port
|
79
|
+
|
80
|
+
begin
|
81
|
+
set_timeout(timeout)
|
82
|
+
|
83
|
+
remote_addr = @resolver.resolve(remote_addr, timeout: time_remaining(timeout))
|
84
|
+
local_addr = @resolver.resolve(local_addr, timeout: time_remaining(timeout)) if local_addr
|
85
|
+
raise ArgumentError, "expected IPAddr from resolver, got #{remote_addr.class}" unless remote_addr.is_a?(IPAddr)
|
86
|
+
|
87
|
+
if remote_addr.ipv4?
|
88
|
+
@family = ::Socket::AF_INET
|
89
|
+
elsif remote_addr.ipv6?
|
90
|
+
@family = ::Socket::AF_INET6
|
91
|
+
else raise Socketry::AddressError, "unsupported IP address family: #{remote_addr}"
|
92
|
+
end
|
93
|
+
|
94
|
+
socket = @socket_class.new(@family, ::Socket::SOCK_STREAM, 0)
|
95
|
+
socket.bind Addrinfo.tcp(local_addr.to_s, local_port) if local_addr
|
96
|
+
remote_sockaddr = ::Socket.sockaddr_in(remote_port, remote_addr.to_s)
|
97
|
+
|
98
|
+
# Note: `exception: false` for Socket#connect_nonblock is only supported in Ruby 2.3+
|
99
|
+
begin
|
100
|
+
socket.connect_nonblock(remote_sockaddr)
|
101
|
+
rescue Errno::EINPROGRESS, Errno::EALREADY
|
102
|
+
# Earlier JRuby 9.x versions do not seem to correctly support Socket#wait_writable in this case
|
103
|
+
# Newer versions seem to behave correctly
|
104
|
+
retry if IO.select(nil, [socket], nil, time_remaining(timeout))
|
105
|
+
|
106
|
+
socket.close
|
107
|
+
raise Socketry::TimeoutError, "connection to #{remote_addr}:#{remote_port} timed out"
|
108
|
+
rescue Errno::EISCONN
|
109
|
+
# Sometimes raised when we've connected successfully
|
110
|
+
end
|
111
|
+
|
112
|
+
@socket = socket
|
113
|
+
ensure
|
114
|
+
clear_timeout(timeout)
|
115
|
+
end
|
116
|
+
|
117
|
+
self
|
118
|
+
end
|
119
|
+
|
120
|
+
# Re-establish a lost TCP connection
|
121
|
+
#
|
122
|
+
# @param timeout [Numeric] Number of seconds to wait before aborting re-connect
|
123
|
+
# @raise [Socketry::StateError] not in a disconnected state
|
124
|
+
def reconnect(timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:connect])
|
125
|
+
ensure_disconnected
|
126
|
+
raise StateError, "can't reconnect: never completed initial connection" unless @remote_addr
|
127
|
+
connect(@remote_addr, @remote_port, local_addr: @local_addr, local_port: @local_port, timeout: timeout)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Wrap a Ruby/low-level socket in an Socketry::TCP::Socket
|
131
|
+
#
|
132
|
+
# @param socket [::Socket] (or specified socket_class) low-level socket to wrap
|
133
|
+
def from_socket(socket)
|
134
|
+
ensure_disconnected
|
135
|
+
raise TypeError, "expected #{@socket_class}, got #{socket.class}" unless socket.is_a?(@socket_class)
|
136
|
+
@socket = socket
|
137
|
+
self
|
138
|
+
end
|
139
|
+
|
140
|
+
# Perform a non-blocking read operation
|
141
|
+
#
|
142
|
+
# @param size [Fixnum] number of bytes to attempt to read
|
143
|
+
# @param outbuf [String, NilClass] an optional buffer into which data should be read
|
144
|
+
# @raise [Socketry::Error] an I/O operation failed
|
145
|
+
# @return [String, :wait_readable] data read, or :wait_readable if operation would block
|
146
|
+
def read_nonblock(size, outbuf: nil)
|
147
|
+
ensure_connected
|
148
|
+
case outbuf
|
149
|
+
when String
|
150
|
+
@socket.read_nonblock(size, outbuf, exception: false)
|
151
|
+
when NilClass
|
152
|
+
@socket.read_nonblock(size, exception: false)
|
153
|
+
else raise TypeError, "unexpected outbuf class: #{outbuf.class}"
|
154
|
+
end
|
155
|
+
rescue IO::WaitReadable
|
156
|
+
# Some buggy Rubies continue to raise this exception
|
157
|
+
:wait_readable
|
158
|
+
rescue IOError => ex
|
159
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
160
|
+
end
|
161
|
+
|
162
|
+
# Read a partial amounth of data, blocking until it becomes available
|
163
|
+
#
|
164
|
+
# @param size [Fixnum] number of bytes to attempt to read
|
165
|
+
# @raise [Socketry::Error] an I/O operation failed
|
166
|
+
# @return [String]
|
167
|
+
def readpartial(size, outbuf: nil, timeout: @read_timeout)
|
168
|
+
set_timeout(timeout)
|
169
|
+
|
170
|
+
begin
|
171
|
+
while (result = read_nonblock(size, outbuf: outbuf)) == :wait_readable
|
172
|
+
next if @socket.wait_readable(read_timeout)
|
173
|
+
raise TimeoutError, "read timed out after #{timeout} seconds"
|
174
|
+
end
|
175
|
+
ensure
|
176
|
+
clear_timeout(timeout)
|
177
|
+
end
|
178
|
+
|
179
|
+
result || :eof
|
180
|
+
end
|
181
|
+
|
182
|
+
# Perform a non-blocking write operation
|
183
|
+
#
|
184
|
+
# @param data [String] number of bytes to attempt to read
|
185
|
+
# @raise [Socketry::Error] an I/O operation failed
|
186
|
+
# @return [Fixnum, :wait_writable] number of bytes written, or :wait_writable if op would block
|
187
|
+
def write_nonblock(data)
|
188
|
+
ensure_connected
|
189
|
+
@socket.write_nonblock(data, exception: false)
|
190
|
+
rescue IO::WaitWriteable
|
191
|
+
# Some buggy Rubies continue to raise this exception
|
192
|
+
:wait_writable
|
193
|
+
rescue IOError => ex
|
194
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
195
|
+
end
|
196
|
+
|
197
|
+
# Write a partial amounth of data, blocking until it's completed
|
198
|
+
#
|
199
|
+
# @param data [String] number of bytes to attempt to read
|
200
|
+
# @raise [Socketry::Error] an I/O operation failed
|
201
|
+
# @return [Fixnum, :wait_writable] number of bytes written, or :wait_writable if op would block
|
202
|
+
def writepartial(data, timeout: @write_timeout)
|
203
|
+
set_timeout(timeout)
|
204
|
+
|
205
|
+
begin
|
206
|
+
while (result = write_nonblock(data)) == :wait_writable
|
207
|
+
next if @socket.wait_writable(read_timeout)
|
208
|
+
raise TimeoutError, "write timed out after #{timeout} seconds"
|
209
|
+
end
|
210
|
+
ensure
|
211
|
+
clear_timeout(timeout)
|
212
|
+
end
|
213
|
+
|
214
|
+
result || :eof
|
215
|
+
end
|
216
|
+
|
217
|
+
# Check whether Nagle's algorithm has been disabled
|
218
|
+
#
|
219
|
+
# @return [true] Nagle's algorithm has been explicitly disabled
|
220
|
+
# @return [false] Nagle's algorithm is enabled (default)
|
221
|
+
def nodelay
|
222
|
+
ensure_connected
|
223
|
+
@socket.getsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY).int.nonzero?
|
224
|
+
end
|
225
|
+
|
226
|
+
# Disable or enable Nagle's algorithm
|
227
|
+
#
|
228
|
+
# @param flag [true, false] disable or enable coalescing multiple writesusing Nagle's algorithm
|
229
|
+
def nodelay=(flag)
|
230
|
+
ensure_connected
|
231
|
+
@socket.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, flag ? 1 : 0)
|
232
|
+
end
|
233
|
+
|
234
|
+
# Return a raw Ruby I/O object
|
235
|
+
#
|
236
|
+
# @return [IO] Ruby I/O object
|
237
|
+
def to_io
|
238
|
+
ensure_connected
|
239
|
+
::IO.try_convert(@socket)
|
240
|
+
end
|
241
|
+
|
242
|
+
# Close the socket
|
243
|
+
#
|
244
|
+
# @return [true, false] true if the socket was open, false if closed
|
245
|
+
def close
|
246
|
+
return false unless connected?
|
247
|
+
@socket.close
|
248
|
+
true
|
249
|
+
ensure
|
250
|
+
@socket = nil
|
251
|
+
end
|
252
|
+
|
253
|
+
# Is the socket currently connected?
|
254
|
+
#
|
255
|
+
# This method returns the local connection state. However, it's possible
|
256
|
+
# the remote side has closed the connection, so it's not actually
|
257
|
+
# possible to actually know if the socket is actually still open without
|
258
|
+
# reading from or writing to it. It's sort of like the Heisenberg
|
259
|
+
# uncertainty principle of sockets.
|
260
|
+
#
|
261
|
+
# @return [true, false] do we locally think the socket is open?
|
262
|
+
def connected?
|
263
|
+
@socket != nil
|
264
|
+
end
|
265
|
+
|
266
|
+
private
|
267
|
+
|
268
|
+
def ensure_connected
|
269
|
+
return true if connected?
|
270
|
+
raise StateError, "not connected"
|
271
|
+
end
|
272
|
+
|
273
|
+
def ensure_disconnected
|
274
|
+
return true unless connected?
|
275
|
+
raise StateError, "already connected"
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
# Timeout subsystem
|
5
|
+
module Timeout
|
6
|
+
DEFAULT_TIMER = Hitimes::Interval
|
7
|
+
|
8
|
+
# Default timeouts (in seconds)
|
9
|
+
DEFAULT_TIMEOUTS = {
|
10
|
+
read: 5,
|
11
|
+
write: 5,
|
12
|
+
connect: 5
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
# Start a timer in the included object
|
16
|
+
#
|
17
|
+
# @param timer [#start, #to_f] a timer object (ideally monotonic)
|
18
|
+
# @return [true] timer started successfully
|
19
|
+
# @raise [Socketry::InternalError] if timer is already started
|
20
|
+
def start_timer(timer = DEFAULT_TIMER_CLASS.new)
|
21
|
+
raise Socketry::InternalError, "timer already started" if defined?(@timer)
|
22
|
+
raise Socketry::InternalError, "deadline already set" if defined?(@deadline)
|
23
|
+
|
24
|
+
@deadline = nil
|
25
|
+
@timer = timer
|
26
|
+
@timer.start
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
# Return how long since the timer has been started
|
31
|
+
#
|
32
|
+
# @return [Float] number of seconds since the timer has been started
|
33
|
+
# @raise [Socketry::InternalError] if timer has not been started
|
34
|
+
def lifetime
|
35
|
+
raise Socketry::InternalError, "timer not started" unless @timer
|
36
|
+
@timer.to_f
|
37
|
+
end
|
38
|
+
|
39
|
+
# Set a timeout. Only one timeout may be active at a given time for a given object.
|
40
|
+
#
|
41
|
+
# @param timeout [Numeric] number of seconds until the timeout is reached
|
42
|
+
# @return [Float] deadline (relative to #lifetime) at which the timeout is reached
|
43
|
+
# @raise [Socketry::InternalError] if timeout is already set
|
44
|
+
def set_timeout(timeout)
|
45
|
+
raise Socketry::InternalError, "deadline already set" if @deadline
|
46
|
+
return unless timeout
|
47
|
+
@deadline = lifetime + timeout
|
48
|
+
end
|
49
|
+
|
50
|
+
# Clear an already-set timeout
|
51
|
+
#
|
52
|
+
# @param timeout [Numeric] to gauge whether the timeout actually needs to be cleared
|
53
|
+
# @raise [Socketry::InternalError] if timeout has not been set
|
54
|
+
def clear_timeout(timeout)
|
55
|
+
return unless timeout
|
56
|
+
raise Socketry::InternalError, "no deadline set" unless @deadline
|
57
|
+
@deadline = nil
|
58
|
+
end
|
59
|
+
|
60
|
+
# Calculate number of seconds remaining until we hit the timeout
|
61
|
+
#
|
62
|
+
# @param timeout [Numeric] to gauge whether a timeout needs to be calculated
|
63
|
+
# @return [Float] number of seconds remaining until we hit the timeout
|
64
|
+
# @raise [Socketry::TimeoutError] if we've already hit the timeout
|
65
|
+
# @raise [Socketry::InternalError] if timeout has not been set
|
66
|
+
def time_remaining(timeout)
|
67
|
+
return unless timeout
|
68
|
+
raise Socketry::InternalError, "no deadline set" unless @deadline
|
69
|
+
remaining = @deadline - lifetime
|
70
|
+
raise Socketry::TimeoutError, "time expired" if remaining <= 0
|
71
|
+
remaining
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Socketry
|
4
|
+
# User Datagram Protocol: "fire-and-forget" packet protocol
|
5
|
+
module UDP
|
6
|
+
# User Datagram Protocol sockets
|
7
|
+
class Socket
|
8
|
+
include Socketry::Timeout
|
9
|
+
|
10
|
+
attr_reader :read_timeout, :write_timeout, :resolver, :socket_class
|
11
|
+
|
12
|
+
# Create a UDP socket matching the given socket's address family
|
13
|
+
#
|
14
|
+
# @param remote_addr [String] address to connect/bind to
|
15
|
+
# @return [Socketry::UDP::Socket]
|
16
|
+
def self.from_addr(remote_addr, resolver: Socketry::Resolver::DEFAULT_RESOLVER)
|
17
|
+
addr = resolver.resolve(remote_addr)
|
18
|
+
if addr.ipv4?
|
19
|
+
new(family: :ipv4)
|
20
|
+
elsif addr.ipv6?
|
21
|
+
new(family: :ipv6)
|
22
|
+
else raise Socketry::AddressError, "unsupported IP address family: #{addr}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Bind to the given address and port
|
27
|
+
#
|
28
|
+
# @return [Socketry::UDP::Socket]
|
29
|
+
def self.bind(remote_addr, remote_port, resolver: Socketry::Resolver::DEFAULT_RESOLVER)
|
30
|
+
from_addr(remote_addr, resolver: resolver).bind(remote_addr, remote_port)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Connect to the given address and port
|
34
|
+
#
|
35
|
+
# @return [Socketry::UDP::Socket]
|
36
|
+
def self.connect(remote_addr, remote_port, resolver: Socketry::Resolver::DEFAULT_RESOLVER)
|
37
|
+
from_addr(remote_addr, resolver: resolver).connect(remote_addr, remote_port)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Create a new UDP socket
|
41
|
+
#
|
42
|
+
# @return [Socketry::UDP::Socket]
|
43
|
+
def initialize(
|
44
|
+
family: :ipv4,
|
45
|
+
read_timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:read],
|
46
|
+
write_timeout: Socketry::Timeout::DEFAULT_TIMEOUTS[:write],
|
47
|
+
timer: Socketry::Timeout::DEFAULT_TIMER.new,
|
48
|
+
resolver: Socketry::Resolver::DEFAULT_RESOLVER,
|
49
|
+
socket_class: ::UDPSocket
|
50
|
+
)
|
51
|
+
case family
|
52
|
+
when :ipv4
|
53
|
+
@address_family = ::Socket::AF_INET
|
54
|
+
when :ipv6
|
55
|
+
@address_family = ::Socket::AF_INET6
|
56
|
+
when ::Socket::AF_INET, ::Socket::AF_INET6
|
57
|
+
@address_family = address_family
|
58
|
+
else raise ArgumentError, "invalid address family: #{address_family.inspect}"
|
59
|
+
end
|
60
|
+
|
61
|
+
@socket = socket_class.new(@address_family)
|
62
|
+
@read_timeout = read_timeout
|
63
|
+
@write_timeout = write_timeout
|
64
|
+
@resolver = resolver
|
65
|
+
|
66
|
+
start_timer(timer)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Bind to the given address and port
|
70
|
+
#
|
71
|
+
# @return [self]
|
72
|
+
def bind(remote_addr, remote_port)
|
73
|
+
@socket.bind(@resolver.resolve(remote_addr), remote_port)
|
74
|
+
self
|
75
|
+
rescue => ex
|
76
|
+
# TODO: more specific exceptions
|
77
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
78
|
+
end
|
79
|
+
|
80
|
+
# Create a new UDP socket
|
81
|
+
#
|
82
|
+
# @return [self]
|
83
|
+
def connect(remote_addr, remote_port)
|
84
|
+
@socket.connect(@resolver.resolve(remote_addr), remote_port)
|
85
|
+
self
|
86
|
+
rescue => ex
|
87
|
+
# TODO: more specific exceptions
|
88
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
89
|
+
end
|
90
|
+
|
91
|
+
# Perform a non-blocking receive
|
92
|
+
#
|
93
|
+
# @return [String, :wait_readable] received packet or indication to wait
|
94
|
+
def recvfrom_nonblock(maxlen)
|
95
|
+
@socket.recvfrom_nonblock(maxlen)
|
96
|
+
rescue ::IO::WaitReadable
|
97
|
+
:wait_readable
|
98
|
+
rescue => ex
|
99
|
+
# TODO: more specific exceptions
|
100
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
101
|
+
end
|
102
|
+
|
103
|
+
# Perform a blocking receive
|
104
|
+
#
|
105
|
+
# @return [String] received data
|
106
|
+
def recvfrom(maxlen, timeout: @read_timeout)
|
107
|
+
set_timeout(timeout)
|
108
|
+
|
109
|
+
begin
|
110
|
+
while (result = recvfrom_nonblock(maxlen)) == :wait_readable
|
111
|
+
next if @socket.wait_readable(time_remaining(timeout))
|
112
|
+
raise Socketry::TimeoutError, "recvfrom timed out after #{timeout} seconds"
|
113
|
+
end
|
114
|
+
ensure
|
115
|
+
clear_timeout(imeout)
|
116
|
+
end
|
117
|
+
|
118
|
+
result
|
119
|
+
end
|
120
|
+
|
121
|
+
# Send data to the given host and port
|
122
|
+
def send(msg, host:, port:)
|
123
|
+
@socket.send(msg, 0, @resolver.resolve(host), port)
|
124
|
+
rescue => ex
|
125
|
+
# TODO: more specific exceptions
|
126
|
+
raise Socketry::Error, ex.message, ex.backtrace
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
data/socketry.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "socketry/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "socketry"
|
8
|
+
spec.version = Socketry::VERSION
|
9
|
+
spec.authors = ["Tony Arcieri", "Zachary Anker"]
|
10
|
+
spec.email = ["bascule@gmail.com"]
|
11
|
+
spec.licenses = ["MIT"]
|
12
|
+
spec.homepage = "https://github.com/celluloid/socketry/"
|
13
|
+
spec.summary = "High-level wrappers for Ruby sockets with advanced thread-safe timeout support"
|
14
|
+
spec.description = <<-DESCRIPTION.strip.gsub(/\s+/, " ")
|
15
|
+
Socketry wraps Ruby's sockets with an advanced timeout engine which is able to provide multiple
|
16
|
+
simultaneous timeout behaviors in a thread-safe way.
|
17
|
+
DESCRIPTION
|
18
|
+
|
19
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
20
|
+
spec.bindir = "exe"
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
spec.required_ruby_version = ">= 2.2.2"
|
25
|
+
|
26
|
+
spec.add_runtime_dependency "hitimes", ">= 1.2"
|
27
|
+
|
28
|
+
spec.add_development_dependency "bundler", "~> 1.0"
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: socketry
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tony Arcieri
|
8
|
+
- Zachary Anker
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2016-09-12 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: hitimes
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '1.2'
|
21
|
+
type: :runtime
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '1.2'
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: bundler
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - "~>"
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: '1.0'
|
35
|
+
type: :development
|
36
|
+
prerelease: false
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - "~>"
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '1.0'
|
42
|
+
description: Socketry wraps Ruby's sockets with an advanced timeout engine which is
|
43
|
+
able to provide multiple simultaneous timeout behaviors in a thread-safe way.
|
44
|
+
email:
|
45
|
+
- bascule@gmail.com
|
46
|
+
executables: []
|
47
|
+
extensions: []
|
48
|
+
extra_rdoc_files: []
|
49
|
+
files:
|
50
|
+
- ".gitignore"
|
51
|
+
- ".rspec"
|
52
|
+
- ".rubocop.yml"
|
53
|
+
- ".ruby-version"
|
54
|
+
- ".travis.yml"
|
55
|
+
- CHANGES.md
|
56
|
+
- Gemfile
|
57
|
+
- Guardfile
|
58
|
+
- LICENSE.txt
|
59
|
+
- README.md
|
60
|
+
- Rakefile
|
61
|
+
- lib/socketry.rb
|
62
|
+
- lib/socketry/exceptions.rb
|
63
|
+
- lib/socketry/resolver/resolv.rb
|
64
|
+
- lib/socketry/resolver/system.rb
|
65
|
+
- lib/socketry/ssl/server.rb
|
66
|
+
- lib/socketry/ssl/socket.rb
|
67
|
+
- lib/socketry/tcp/server.rb
|
68
|
+
- lib/socketry/tcp/socket.rb
|
69
|
+
- lib/socketry/timeout.rb
|
70
|
+
- lib/socketry/udp/socket.rb
|
71
|
+
- lib/socketry/version.rb
|
72
|
+
- socketry.gemspec
|
73
|
+
homepage: https://github.com/celluloid/socketry/
|
74
|
+
licenses:
|
75
|
+
- MIT
|
76
|
+
metadata: {}
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - ">="
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: 2.2.2
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
requirements: []
|
92
|
+
rubyforge_project:
|
93
|
+
rubygems_version: 2.5.1
|
94
|
+
signing_key:
|
95
|
+
specification_version: 4
|
96
|
+
summary: High-level wrappers for Ruby sockets with advanced thread-safe timeout support
|
97
|
+
test_files: []
|