ftw 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +49 -0
- data/lib/ftw/agent.rb +40 -0
- data/lib/ftw/connection.rb +231 -0
- data/lib/ftw/crlf.rb +6 -0
- data/lib/ftw/dns.rb +62 -0
- data/lib/ftw/http/headers.rb +122 -0
- data/lib/ftw/http/message.rb +92 -0
- data/lib/ftw/namespace.rb +3 -0
- data/lib/ftw/request.rb +102 -0
- data/lib/ftw/version.rb +5 -0
- data/lib/net-ftw.rb +1 -0
- data/lib/net/ftw.rb +5 -0
- data/lib/net/ftw/agent.rb +10 -0
- data/lib/net/ftw/connection.rb +296 -0
- data/lib/net/ftw/connection2.rb +247 -0
- data/lib/net/ftw/crlf.rb +6 -0
- data/lib/net/ftw/dns.rb +57 -0
- data/lib/net/ftw/http.rb +2 -0
- data/lib/net/ftw/http/client.rb +116 -0
- data/lib/net/ftw/http/client2.rb +80 -0
- data/lib/net/ftw/http/connection.rb +42 -0
- data/lib/net/ftw/http/headers.rb +122 -0
- data/lib/net/ftw/http/machine.rb +38 -0
- data/lib/net/ftw/http/message.rb +91 -0
- data/lib/net/ftw/http/request.rb +80 -0
- data/lib/net/ftw/http/response.rb +80 -0
- data/lib/net/ftw/http/server.rb +5 -0
- data/lib/net/ftw/machine.rb +59 -0
- data/lib/net/ftw/namespace.rb +6 -0
- data/lib/net/ftw/protocol/tls.rb +12 -0
- data/lib/net/ftw/websocket.rb +139 -0
- data/test/net/ftw/crlf.rb +12 -0
- data/test/net/ftw/http/dns.rb +6 -0
- data/test/net/ftw/http/headers.rb +50 -0
- data/test/testing.rb +23 -0
- metadata +82 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
require "ftw/http/headers"
|
3
|
+
require "ftw/crlf"
|
4
|
+
|
5
|
+
# HTTP Message, RFC2616
|
6
|
+
module FTW::HTTP::Message
|
7
|
+
include FTW::CRLF
|
8
|
+
|
9
|
+
# The HTTP headers. See FTW::HTTP::Headers
|
10
|
+
# RFC2616 5.3 - <http://tools.ietf.org/html/rfc2616#section-5.3>
|
11
|
+
attr_reader :headers
|
12
|
+
|
13
|
+
# The HTTP version. See VALID_VERSIONS for valid versions.
|
14
|
+
# This will always be a Numeric object.
|
15
|
+
# Both Request and Responses have version, so put it in the parent class.
|
16
|
+
attr_accessor :version
|
17
|
+
VALID_VERSIONS = [1.0, 1.1]
|
18
|
+
|
19
|
+
# A new HTTP Message. You probably won't use this class much.
|
20
|
+
# See RFC2616 section 4: <http://tools.ietf.org/html/rfc2616#section-4>
|
21
|
+
# See Request and Response.
|
22
|
+
public
|
23
|
+
def initialize
|
24
|
+
@headers = FTW::HTTP::Headers.new
|
25
|
+
@body = nil
|
26
|
+
end # def initialize
|
27
|
+
|
28
|
+
# get a header value
|
29
|
+
public
|
30
|
+
def [](header)
|
31
|
+
return @headers[header]
|
32
|
+
end # def []
|
33
|
+
|
34
|
+
public
|
35
|
+
def []=(header, value)
|
36
|
+
@headers[header] = header
|
37
|
+
end # def []=
|
38
|
+
|
39
|
+
# See RFC2616 section 4.3: <http://tools.ietf.org/html/rfc2616#section-4.3>
|
40
|
+
public
|
41
|
+
def body=(message_body)
|
42
|
+
# TODO(sissel): if message_body is a string, set Content-Length header
|
43
|
+
# TODO(sissel): if it's an IO object, set Transfer-Encoding to chunked
|
44
|
+
# TODO(sissel): if it responds to each or appears to be Enumerable, then
|
45
|
+
# set Transfer-Encoding to chunked.
|
46
|
+
@body = message_body
|
47
|
+
end # def body=
|
48
|
+
|
49
|
+
public
|
50
|
+
def body
|
51
|
+
# TODO(sissel): verification todos follow...
|
52
|
+
# TODO(sissel): RFC2616 section 4.3 - if there is a message body
|
53
|
+
# then one of "Transfer-Encoding" *or* "Content-Length" MUST be present.
|
54
|
+
# otherwise, if neither header is present, no body is present.
|
55
|
+
# TODO(sissel): Responses to HEAD requests or those with status 1xx, 204,
|
56
|
+
# or 304 MUST NOT have a body. All other requests have a message body,
|
57
|
+
# even if that body is of zero length.
|
58
|
+
return @body
|
59
|
+
end # def body
|
60
|
+
|
61
|
+
# Does this message have a message body?
|
62
|
+
public
|
63
|
+
def body?
|
64
|
+
return @body.nil?
|
65
|
+
end # def body?
|
66
|
+
|
67
|
+
# Set the HTTP version. Must be a valid version. See VALID_VERSIONS.
|
68
|
+
public
|
69
|
+
def version=(ver)
|
70
|
+
# Accept string "1.0" or simply "1", etc.
|
71
|
+
ver = ver.to_f if !ver.is_a?(Float)
|
72
|
+
|
73
|
+
if !VALID_VERSIONS.include?(ver)
|
74
|
+
raise ArgumentError.new("#{self.class.name}#version = #{ver.inspect} is" \
|
75
|
+
"invalid. It must be a number, one of #{VALID_VERSIONS.join(", ")}")
|
76
|
+
end
|
77
|
+
@version = ver
|
78
|
+
end # def version=
|
79
|
+
|
80
|
+
# Serialize this Request according to RFC2616
|
81
|
+
# Note: There is *NO* trailing CRLF. This is intentional.
|
82
|
+
# The RFC defines:
|
83
|
+
# generic-message = start-line
|
84
|
+
# *(message-header CRLF)
|
85
|
+
# CRLF
|
86
|
+
# [ message-body ]
|
87
|
+
# Thus, the CRLF between header and body is not part of the header.
|
88
|
+
public
|
89
|
+
def to_s
|
90
|
+
return [start_line, @headers].join(CRLF)
|
91
|
+
end
|
92
|
+
end # class FTW::HTTP::Message
|
data/lib/ftw/request.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
require "ftw/http/message"
|
3
|
+
require "addressable/uri" # gem addressable
|
4
|
+
require "uri" # ruby stdlib
|
5
|
+
require "http/parser" # gem http_parser.rb
|
6
|
+
require "ftw/crlf"
|
7
|
+
|
8
|
+
# An HTTP Request.
|
9
|
+
#
|
10
|
+
# See RFC2616 section 5: <http://tools.ietf.org/html/rfc2616#section-5>
|
11
|
+
class FTW::Request
|
12
|
+
include FTW::HTTP::Message
|
13
|
+
include FTW::CRLF
|
14
|
+
|
15
|
+
# The http method. Like GET, PUT, POST, etc..
|
16
|
+
# RFC2616 5.1.1 - <http://tools.ietf.org/html/rfc2616#section-5.1.1>
|
17
|
+
#
|
18
|
+
# Warning: this accessor obscures the ruby Kernel#method() method.
|
19
|
+
# I would like to call this 'verb', but my preference is first to adhere to
|
20
|
+
# RFC terminology. Further, ruby's stdlib Net::HTTP calls this 'method' as
|
21
|
+
# well (See Net::HTTPGenericRequest).
|
22
|
+
attr_accessor :method
|
23
|
+
|
24
|
+
# This is the Request-URI. Many people call this the 'path' of the request.
|
25
|
+
# RFC2616 5.1.2 - <http://tools.ietf.org/html/rfc2616#section-5.1.2>
|
26
|
+
attr_accessor :request_uri
|
27
|
+
|
28
|
+
# Lemmings. Everyone else calls Request-URI the 'path' - so I should too.
|
29
|
+
alias_method :path, :request_uri
|
30
|
+
|
31
|
+
public
|
32
|
+
def initialize(uri=nil)
|
33
|
+
super()
|
34
|
+
use_uri(uri) if !uri.nil?
|
35
|
+
@version = 1.1
|
36
|
+
end # def initialize
|
37
|
+
|
38
|
+
# Set the connection to use for this request.
|
39
|
+
public
|
40
|
+
def connection=(connection)
|
41
|
+
@connection = connection
|
42
|
+
end # def connection=
|
43
|
+
|
44
|
+
public
|
45
|
+
def execute(connection)
|
46
|
+
connection.write(to_s + CRLF)
|
47
|
+
|
48
|
+
parser = HTTP::Parser.new
|
49
|
+
parser.on_headers_complete = proc { state = :body; :stop }
|
50
|
+
|
51
|
+
data = connection.read(16384)
|
52
|
+
parser << data
|
53
|
+
# TODO(sissel): use connection.unread() if we finish reading headers
|
54
|
+
# and there's still some data left that is part of the body.
|
55
|
+
end # def execute
|
56
|
+
|
57
|
+
# TODO(sissel): Methods to write:
|
58
|
+
# 1. Parsing a request, use HTTP::Parser from http_parser.rb
|
59
|
+
# 2. Building a request from a URI or Addressable::URI
|
60
|
+
|
61
|
+
public
|
62
|
+
def use_uri(uri)
|
63
|
+
# Convert URI objects to Addressable::URI
|
64
|
+
uri = Addressable::URI.parse(uri.to_s) if uri.is_a?(URI)
|
65
|
+
|
66
|
+
# TODO(sissel): Use normalized versions of these fields?
|
67
|
+
# uri.host
|
68
|
+
# uri.port
|
69
|
+
# uri.scheme
|
70
|
+
# uri.path
|
71
|
+
# uri.password
|
72
|
+
# uri.user
|
73
|
+
@request_uri = uri.path
|
74
|
+
@headers.set("Host", uri.host)
|
75
|
+
|
76
|
+
# TODO(sissel): support authentication
|
77
|
+
end # def use_uri
|
78
|
+
|
79
|
+
# Set the method for this request. Usually something like "GET" or "PUT"
|
80
|
+
# etc. See <http://tools.ietf.org/html/rfc2616#section-5.1.1>
|
81
|
+
public
|
82
|
+
def method=(method)
|
83
|
+
# RFC2616 5.1.1 doesn't say the method has to be uppercase.
|
84
|
+
# It can be any 'token' besides the ones defined in section 5.1.1:
|
85
|
+
# The grammar for 'token' is:
|
86
|
+
# token = 1*<any CHAR except CTLs or separators>
|
87
|
+
# TODO(sissel): support section 5.1.1 properly. Don't upcase, but
|
88
|
+
# maybe upcase things that are defined in 5.1.1 like GET, etc.
|
89
|
+
@method = method.upcase
|
90
|
+
end # def method=
|
91
|
+
|
92
|
+
# Get the request line (first line of the http request)
|
93
|
+
# From the RFC: Request-Line = Method SP Request-URI SP HTTP-Version CRLF
|
94
|
+
#
|
95
|
+
# Note: I skip the trailing CRLF. See the to_s method where it is provided.
|
96
|
+
def request_line
|
97
|
+
return "#{method} #{request_uri} HTTP/#{version}"
|
98
|
+
end # def request_line
|
99
|
+
|
100
|
+
# Define the Message's start_line as request_line
|
101
|
+
alias_method :start_line, :request_line
|
102
|
+
end # class FTW::Request < Message
|
data/lib/ftw/version.rb
ADDED
data/lib/net-ftw.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "net/ftw"
|
data/lib/net/ftw.rb
ADDED
@@ -0,0 +1,296 @@
|
|
1
|
+
require "cabin" # rubygem "cabin"
|
2
|
+
require "net/ftw/dns"
|
3
|
+
require "net/ftw/namespace"
|
4
|
+
require "socket"
|
5
|
+
require "timeout" # ruby stdlib, just for the Timeout exception.
|
6
|
+
require "backport-bij" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
|
7
|
+
|
8
|
+
# TODO(sissel): What's the API look like here?
|
9
|
+
# EventMachine::Connection has these:
|
10
|
+
# * events: post_init (and connection_completed), receive_data, unbind
|
11
|
+
# * methods: send_data, close
|
12
|
+
# Socket has
|
13
|
+
# * no events
|
14
|
+
# * methods: connect, read, write, close
|
15
|
+
#
|
16
|
+
# Actual events:
|
17
|
+
# * connected
|
18
|
+
# * disconnected(reason)
|
19
|
+
# * timeout, connection reset, connection refused, write error, read
|
20
|
+
# error, etc
|
21
|
+
# * data received
|
22
|
+
#
|
23
|
+
# Methods
|
24
|
+
# * send data
|
25
|
+
# * reconnect
|
26
|
+
# * get socket
|
27
|
+
# * disconnect
|
28
|
+
#
|
29
|
+
|
30
|
+
# A network connection. This is TCP.
|
31
|
+
#
|
32
|
+
# Example:
|
33
|
+
#
|
34
|
+
# conn = Net::FTW::Connection.new("www.google.com:80")
|
35
|
+
# conn.on(CONNECTED) do |address|
|
36
|
+
# puts "Connected to #{address} (#{conn.peer})"
|
37
|
+
# conn.write("GET / HTTP/1.0\r\n\r\n")
|
38
|
+
# end
|
39
|
+
# conn.on(DATA) do |data|
|
40
|
+
# puts data
|
41
|
+
# end
|
42
|
+
# conn.run
|
43
|
+
#
|
44
|
+
# You can use IO::select on this objects of this type.
|
45
|
+
class Net::FTW::Connection
|
46
|
+
|
47
|
+
# Events
|
48
|
+
CONNECTED = :connected
|
49
|
+
DISCONNECTED = :disconnected
|
50
|
+
READER_CLOSED = :reader_closed
|
51
|
+
DATA = :data
|
52
|
+
|
53
|
+
# Disconnection reasons
|
54
|
+
TIMEOUT = :timeout
|
55
|
+
REFUSED = :refused
|
56
|
+
LOST = :lost
|
57
|
+
INTENTIONAL = :intentional
|
58
|
+
|
59
|
+
# A new network connection.
|
60
|
+
# The 'destination' argument can be an array of strings or a single string.
|
61
|
+
# String format is expected to be "host:port"
|
62
|
+
#
|
63
|
+
# Example:
|
64
|
+
#
|
65
|
+
# conn = Net::FTW::Connection.new(["1.2.3.4:80", "1.2.3.5:80"])
|
66
|
+
#
|
67
|
+
# If you specify multiple destinations, they are used in a round-robin
|
68
|
+
# decision made during reconnection.
|
69
|
+
public
|
70
|
+
def initialize(destinations)
|
71
|
+
if destinations.is_a?(String)
|
72
|
+
@destinations = [destinations]
|
73
|
+
else
|
74
|
+
@destinations = destinations
|
75
|
+
end
|
76
|
+
|
77
|
+
# Handlers are key => array of callbacks
|
78
|
+
@handlers = Hash.new { |h,k| h[k] = [] }
|
79
|
+
|
80
|
+
on(CONNECTED) { |address| connected(address) }
|
81
|
+
on(DISCONNECTED) { |reason, error| disconnected(reason, error) }
|
82
|
+
|
83
|
+
@connect_timeout = 2
|
84
|
+
|
85
|
+
# Use a fixed-size string that we set to BINARY encoding.
|
86
|
+
# Not all byte sequences are UTF-8 friendly :0
|
87
|
+
@read_size = 16384
|
88
|
+
@read_buffer = " " * @read_size
|
89
|
+
|
90
|
+
# Tell Ruby 1.9 that this string is a binary string, not utf-8 or somesuch.
|
91
|
+
if @read_buffer.respond_to?(:force_encoding)
|
92
|
+
@read_buffer.force_encoding("BINARY")
|
93
|
+
end
|
94
|
+
|
95
|
+
# TODO(sissel): Validate @destinations
|
96
|
+
end # def initialize
|
97
|
+
|
98
|
+
# Register an event callback
|
99
|
+
# Valid events:
|
100
|
+
#
|
101
|
+
# * Net::FTW::Connection::CONNECTED - 1 argument, the host:port string connected to.
|
102
|
+
# * Net::FTW::Connection::DISCONNECTED - 2 arguments, the reason and the
|
103
|
+
# exception (if any)
|
104
|
+
# * Net::FTW::Connection::DATA - 1 argument to block, the data read
|
105
|
+
#
|
106
|
+
# Disconnection reasons:
|
107
|
+
# * :timeout
|
108
|
+
# * :refused
|
109
|
+
# * :closed
|
110
|
+
# * :lost
|
111
|
+
public
|
112
|
+
def on(event, &block)
|
113
|
+
@handlers[event] << block
|
114
|
+
end # def on
|
115
|
+
|
116
|
+
# Trigger an event with arguments.
|
117
|
+
# All callbacks for the event will be invoked in the order they were
|
118
|
+
# registered. See the 'on' method for registering callbacks.
|
119
|
+
public
|
120
|
+
def trigger(event, *args)
|
121
|
+
@handlers[event].each do |block|
|
122
|
+
block.call(*args)
|
123
|
+
end
|
124
|
+
end # def trigger
|
125
|
+
|
126
|
+
public
|
127
|
+
def connect(timeout=nil)
|
128
|
+
# TODO(sissel): Raise if we're already connected?
|
129
|
+
close if connected?
|
130
|
+
host, port = @destinations.first.split(":")
|
131
|
+
@destinations = @destinations.rotate # round-robin
|
132
|
+
|
133
|
+
# Do dns resolution on the host. If there are multiple
|
134
|
+
# addresses resolved, return one at random.
|
135
|
+
@remote_address = Net::FTW::DNS.singleton.resolve_random(host)
|
136
|
+
|
137
|
+
family = @remote_address.include?(":") ? Socket::AF_INET6 : Socket::AF_INET
|
138
|
+
@socket = Socket.new(family, Socket::SOCK_STREAM, 0)
|
139
|
+
sockaddr = Socket.pack_sockaddr_in(port, @remote_address)
|
140
|
+
# TODO(sissel): Support local address binding
|
141
|
+
|
142
|
+
# Connect with timeout
|
143
|
+
begin
|
144
|
+
@socket.connect_nonblock(sockaddr)
|
145
|
+
rescue IO::WaitWritable
|
146
|
+
# Ruby actually raises Errno::EINPROGRESS, but for some reason
|
147
|
+
# the documentation says to use this IO::WaitWritable thing...
|
148
|
+
# I don't get it, but whatever :(
|
149
|
+
if writable?(timeout)
|
150
|
+
begin
|
151
|
+
@socket.connect_nonblock(sockaddr) # check connection failure
|
152
|
+
rescue Errno::EISCONN # Ignore, we're already connected.
|
153
|
+
rescue Errno::ECONNREFUSED => e
|
154
|
+
# Fire 'disconnected' event with reason :refused
|
155
|
+
trigger(DISCONNECTED, :refused, e)
|
156
|
+
end
|
157
|
+
else
|
158
|
+
# Connection timeout
|
159
|
+
# Fire 'disconnected' event with reason :timeout
|
160
|
+
trigger(DISCONNECTED, :connect_timeout, nil)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# We're now connected.
|
165
|
+
trigger(CONNECTED, "#{host}:#{port}")
|
166
|
+
end # def connect
|
167
|
+
|
168
|
+
# Is this Connection connected?
|
169
|
+
public
|
170
|
+
def connected?
|
171
|
+
return @connected
|
172
|
+
end # def connected?
|
173
|
+
|
174
|
+
# Write data to this connection.
|
175
|
+
# This method blocks until the write succeeds unless a timeout is given.
|
176
|
+
#
|
177
|
+
# Returns the number of bytes written (See IO#syswrite)
|
178
|
+
public
|
179
|
+
def write(data, timeout=nil)
|
180
|
+
#connect if !connected?
|
181
|
+
if writable?(timeout)
|
182
|
+
return @socket.syswrite(data)
|
183
|
+
else
|
184
|
+
raise Timeout::Error.new
|
185
|
+
end
|
186
|
+
end # def write
|
187
|
+
|
188
|
+
# Read data from this connection
|
189
|
+
# This method blocks until the read succeeds unless a timeout is given.
|
190
|
+
#
|
191
|
+
# This method is not guaranteed to read exactly 'length' bytes. See
|
192
|
+
# IO#sysread
|
193
|
+
public
|
194
|
+
def read(length, timeout=nil)
|
195
|
+
if readable?(timeout)
|
196
|
+
begin
|
197
|
+
@socket.sysread(length, @read_buffer)
|
198
|
+
return @read_buffer
|
199
|
+
rescue EOFError
|
200
|
+
trigger(READER_CLOSED)
|
201
|
+
end
|
202
|
+
else
|
203
|
+
raise Timeout::Error.new
|
204
|
+
end
|
205
|
+
end # def read
|
206
|
+
|
207
|
+
# End this connection
|
208
|
+
public
|
209
|
+
def disconnect(reason=INTENTIONAL)
|
210
|
+
begin
|
211
|
+
#@reader_closed = true
|
212
|
+
@socket.close_read
|
213
|
+
rescue IOError => e
|
214
|
+
# Ignore
|
215
|
+
end
|
216
|
+
|
217
|
+
begin
|
218
|
+
@socket.close_write
|
219
|
+
rescue IOError => e
|
220
|
+
# Ignore
|
221
|
+
end
|
222
|
+
|
223
|
+
trigger(DISCONNECTED, reason)
|
224
|
+
end # def disconnect
|
225
|
+
|
226
|
+
# Is this connection writable? Returns true if it is writable within
|
227
|
+
# the timeout period. False otherwise.
|
228
|
+
#
|
229
|
+
# The time out is in seconds. Fractional seconds are OK.
|
230
|
+
public
|
231
|
+
def writable?(timeout)
|
232
|
+
ready = IO.select(nil, [@socket], nil, timeout)
|
233
|
+
return !ready.nil?
|
234
|
+
end # def writable?
|
235
|
+
|
236
|
+
# Is this connection readable? Returns true if it is readable within
|
237
|
+
# the timeout period. False otherwise.
|
238
|
+
#
|
239
|
+
# The time out is in seconds. Fractional seconds are OK.
|
240
|
+
public
|
241
|
+
def readable?(timeout)
|
242
|
+
#return false if @reader_closed
|
243
|
+
ready = IO.select([@socket], nil, nil, timeout)
|
244
|
+
return !ready.nil?
|
245
|
+
end # def readable?
|
246
|
+
|
247
|
+
protected
|
248
|
+
def connected(address)
|
249
|
+
@remote_address = nil
|
250
|
+
@connected = true
|
251
|
+
end # def connected
|
252
|
+
|
253
|
+
protected
|
254
|
+
def disconnected(reason, error)
|
255
|
+
@remote_address = nil
|
256
|
+
@connected = false
|
257
|
+
end # def disconnected
|
258
|
+
|
259
|
+
# The host:port
|
260
|
+
public
|
261
|
+
def peer
|
262
|
+
return @remote_address
|
263
|
+
end # def peer
|
264
|
+
|
265
|
+
# Run this Connection.
|
266
|
+
# This is generally meant for Threaded or synchronous operation.
|
267
|
+
# For EventMachine, see TODO(sissel): Implement EventMachine support.
|
268
|
+
public
|
269
|
+
def run
|
270
|
+
connect(@connect_timeout) if not connected?
|
271
|
+
while connected?
|
272
|
+
read_and_trigger
|
273
|
+
end
|
274
|
+
end # def run
|
275
|
+
|
276
|
+
# Read data and trigger data callbacks.
|
277
|
+
#
|
278
|
+
# This is mainly useful if you are implementing your own run loops
|
279
|
+
# and IO::select shenanigans.
|
280
|
+
public
|
281
|
+
def read_and_trigger
|
282
|
+
data = read(@read_size)
|
283
|
+
if data.length == 0
|
284
|
+
disconnect(EOFError)
|
285
|
+
else
|
286
|
+
trigger(DATA, data)
|
287
|
+
end
|
288
|
+
end # def read_and_trigger
|
289
|
+
|
290
|
+
# Support 'to_io' so you can use IO::select on this object.
|
291
|
+
public
|
292
|
+
def to_io
|
293
|
+
return @socket
|
294
|
+
end
|
295
|
+
end # class Net::FTW::Connection
|
296
|
+
|