em-tftp 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.
- checksums.yaml +7 -0
- data/lib/em-tftp.rb +461 -0
- metadata +44 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c5f53c83870189d65638d0835a292998a6fd9711
|
4
|
+
data.tar.gz: 26ae9ceac7c4c6443b93b7346e4af8295095c2d5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3630c5ec775b3a167f1b5e2c201ea0054525daebb45f27451fd5637133b94d5486c7aa4295f39b33ef416a65fe66c805e24d395a223460e42ce839345bf2fd5e
|
7
|
+
data.tar.gz: d26eba509ed7d0baf7c644e1cf1575bb7ba5518081d9acdcd827dddf0e0eabd8706d899e422ee6a8071deec71b2401051b4c5676914a5407f2c4c62d7b5db328
|
data/lib/em-tftp.rb
ADDED
@@ -0,0 +1,461 @@
|
|
1
|
+
# encoding: ASCII-8BIT
|
2
|
+
# EventMachine-based TFTP implementation
|
3
|
+
#
|
4
|
+
# References:
|
5
|
+
# - RFC 1350 (defines TFTP protocol)
|
6
|
+
# - RFC 2347 (negotiatioon of TFTP options)
|
7
|
+
# - RFC 2348 (TFTP block size option)
|
8
|
+
#
|
9
|
+
# Caveats: em-tftp's ReadOnlyFileServer reads an entire file into memory before sending it
|
10
|
+
# This will not work well for huge files. But then again, TFTP is not designed for transferring huge files
|
11
|
+
|
12
|
+
# TODO:
|
13
|
+
# - documentation
|
14
|
+
# - RDoc
|
15
|
+
# - tests
|
16
|
+
|
17
|
+
require 'eventmachine'
|
18
|
+
require 'socket'
|
19
|
+
|
20
|
+
module TFTP
|
21
|
+
|
22
|
+
Error = Class.new(Exception)
|
23
|
+
|
24
|
+
module Protocol
|
25
|
+
ERROR_MESSAGES = {
|
26
|
+
0 => "Unknown error",
|
27
|
+
1 => "File not found",
|
28
|
+
2 => "Access violation",
|
29
|
+
3 => "Disk full or allocation exceeded",
|
30
|
+
4 => "Illegal TFTP operation",
|
31
|
+
5 => "Unknown transfer ID",
|
32
|
+
6 => "File already exists",
|
33
|
+
7 => "No such user"}
|
34
|
+
|
35
|
+
# Used to decode incoming packets
|
36
|
+
# TFTP packets have a simple structure: the port number from the encapsulating UDP datagram doubles as a 'transfer ID',
|
37
|
+
# allowing multiple, simultaneous transfers betweent the same hosts
|
38
|
+
# The first 2 bytes of the payload are an integral 'opcode' from 1-5, in network byte order
|
39
|
+
# The opcodes are: Read ReQuest, Write ReQuest, DATA, ACK, and ERROR
|
40
|
+
# Depending on the opcode, different fields can follow. All fields are either 16-bit integers in network byte order,
|
41
|
+
# or null-terminated strings
|
42
|
+
class Packet
|
43
|
+
OPCODES = {1 => :rrq, 2 => :wrq, 3 => :data, 4 => :ack, 5 => :error}
|
44
|
+
|
45
|
+
def initialize(data)
|
46
|
+
raise TFTP::Error, "TFTP packet too small (#{data.size} bytes)" if data.size < 4
|
47
|
+
raise TFTP::Error, "TFTP packet too large (#{data.size} bytes)" if data.size > 516
|
48
|
+
@opcode = OPCODES[data.getbyte(1)]
|
49
|
+
if opcode == :data
|
50
|
+
@block_no = (data.getbyte(2) << 8) + data.getbyte(3)
|
51
|
+
@data = data[4..-1]
|
52
|
+
elsif opcode == :rrq || opcode == :wrq
|
53
|
+
@filename, _mode = data[2..-1].unpack("Z*Z*") # mode is ignored
|
54
|
+
elsif opcode == :error
|
55
|
+
@err_code = (data.getbyte(2) << 8) + data.getbyte(3)
|
56
|
+
@err_msg = data[4..-2] # don't include null terminator
|
57
|
+
if @err_msg.size == 0
|
58
|
+
@err_msg = ERROR_MESSAGES[@err_code] || "Unknown error"
|
59
|
+
end
|
60
|
+
raise TFTP::Error, @err_msg
|
61
|
+
elsif opcode == :ack
|
62
|
+
@block_no = (data.getbyte(2) << 8) + data.getbyte(3)
|
63
|
+
else
|
64
|
+
raise TFTP::Error, "Unknown TFTP packet type (opcode #{data.getbyte(1)})"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
attr_reader :opcode, :filename, :block_no, :data
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def send_rrq(addr=@peer_addr, port=@peer_port, filename)
|
74
|
+
data = "\0\1" << filename << "\0octet\0"
|
75
|
+
send_packet(addr, port, data)
|
76
|
+
end
|
77
|
+
def send_wrq(addr=@peer_addr, port=@peer_port, filename)
|
78
|
+
data = "\0\2" << filename << "\0octet\0"
|
79
|
+
send_packet(addr, port, data)
|
80
|
+
end
|
81
|
+
def send_block(addr=@peer_addr, port=@peer_port, buffer, pos, block_no)
|
82
|
+
block = buffer.slice(pos, 512) || ""
|
83
|
+
data = "\0\3" << ((block_no >> 8) & 255) << (block_no & 255) << block
|
84
|
+
send_packet(data)
|
85
|
+
pos + 512
|
86
|
+
end
|
87
|
+
def send_ack(addr=@peer_addr, port=@peer_port, block_no)
|
88
|
+
data = "\0\4" << ((block_no >> 8) & 255) << (block_no & 255)
|
89
|
+
send_packet(addr, port, data)
|
90
|
+
end
|
91
|
+
def send_error(addr=@peer_addr, port=@peer_port, code, msg)
|
92
|
+
data = "\0\5" << ((code >> 8) & 255) << (code & 255) << msg << "\0"
|
93
|
+
send_packet(addr, port, data)
|
94
|
+
end
|
95
|
+
def send_packet(addr=@peer_addr, port=@peer_port, data)
|
96
|
+
# this appears useless, but is intended to be overridden
|
97
|
+
$stderr.puts "Sending: #{data} (#{data[0..3].bytes.to_a.join(',')}) to #{addr}:#{port}" if $DEBUG
|
98
|
+
send_datagram(data, addr, port)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# An in-progress file transfer operation
|
103
|
+
# Holds all the state which a TFTP server/client needs to track for a single transfer
|
104
|
+
# Subclasses contain logic specific to client downloads, server downloads, client uploads, server uploads
|
105
|
+
class Transfer
|
106
|
+
include Protocol
|
107
|
+
|
108
|
+
BASE_RETRANSMIT_TIMEOUT = 1.5 # seconds
|
109
|
+
MAX_RETRANSMIT_TIMEOUT = 12
|
110
|
+
|
111
|
+
def initialize(connection, peer_addr, peer_port, listener)
|
112
|
+
# 'connection' is the UDP socket used for this transfer (actually an EventMachine::Connection object)
|
113
|
+
# each transfer uses a newly opened UDP socket, which is closed after the transfer is finished
|
114
|
+
# this is because the UDP port number doubles as a TFTP transfer ID
|
115
|
+
# so using the same port number for 2 successive requests to the same peer may cause problems
|
116
|
+
@connection = connection
|
117
|
+
@peer_addr, @peer_port, @listener = peer_addr, peer_port, listener
|
118
|
+
@buffer = @block_no = @timer = nil
|
119
|
+
@timeout = 1.5
|
120
|
+
end
|
121
|
+
|
122
|
+
attr_reader :peer_addr, :peer_port
|
123
|
+
attr_accessor :buffer, :block_no, :timer, :timeout
|
124
|
+
|
125
|
+
# Abort the file transfer. An optional error message and code can be included.
|
126
|
+
# This should be called if the transfer cannot be completed due to a full hard disk, wrong permissions, etc
|
127
|
+
#
|
128
|
+
# TFTP error codes include:
|
129
|
+
#
|
130
|
+
# 0 Not defined, see error message (if any).
|
131
|
+
# 1 File not found.
|
132
|
+
# 2 Access violation.
|
133
|
+
# 3 Disk full or allocation exceeded.
|
134
|
+
# 4 Illegal TFTP operation.
|
135
|
+
# 5 Unknown transfer ID.
|
136
|
+
# 6 File already exists.
|
137
|
+
# 7 No such user.
|
138
|
+
def abort!(code=0, error_msg=Protocol::ERROR_MESSAGES[code])
|
139
|
+
stop_timer!
|
140
|
+
send_error(code, error_msg || "Unknown error")
|
141
|
+
@connection.close_connection_after_writing
|
142
|
+
@listener.failed(error_msg || "Unknown error")
|
143
|
+
end
|
144
|
+
|
145
|
+
def error!(error_msg)
|
146
|
+
stop_timer!
|
147
|
+
@connection.close_connection
|
148
|
+
@listener.failed(error_msg)
|
149
|
+
end
|
150
|
+
|
151
|
+
private
|
152
|
+
|
153
|
+
def stop_timer!
|
154
|
+
if @timer
|
155
|
+
@timer.cancel
|
156
|
+
@timer = nil
|
157
|
+
end
|
158
|
+
@timeout = BASE_RETRANSMIT_TIMEOUT
|
159
|
+
end
|
160
|
+
def set_timer!(data)
|
161
|
+
@timer = EM::Timer.new(@timeout) do
|
162
|
+
@timeout *= 2
|
163
|
+
if @timeout <= MAX_RETRANSMIT_TIMEOUT
|
164
|
+
send_packet(data)
|
165
|
+
else
|
166
|
+
abort!(0, "Connection timed out")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def finished!
|
172
|
+
stop_timer!
|
173
|
+
@connection.close_connection
|
174
|
+
@listener.completed
|
175
|
+
end
|
176
|
+
|
177
|
+
def send_packet(addr=@peer_addr, port=@peer_port, packet_data)
|
178
|
+
$stderr.puts "Sending: #{packet_data} (#{packet_data[0..3].bytes.to_a.join(',')}) to #{addr}:#{port}" if $DEBUG
|
179
|
+
@connection.send_datagram(packet_data, addr, port)
|
180
|
+
set_timer!(packet_data) unless packet_data.start_with? "\0\5" # no timeout and retransmit for error packets
|
181
|
+
end
|
182
|
+
|
183
|
+
def rrq(packet, port)
|
184
|
+
# a transfer has already started and is not yet finished
|
185
|
+
# a RRQ/WRQ shouldn't arrive at this time
|
186
|
+
abort!(4, "Received unexpected TFTP RRQ packet")
|
187
|
+
end
|
188
|
+
def wrq(packet, port)
|
189
|
+
abort!(4, "Received unexpected TFTP WRQ packet")
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
class Receive < Transfer
|
194
|
+
# Receive a file from the peer
|
195
|
+
def initialize(connection, peer_addr, peer_port, listener)
|
196
|
+
super(connection, peer_addr, peer_port, listener)
|
197
|
+
@buffer = ""
|
198
|
+
@block_no = 1
|
199
|
+
end
|
200
|
+
|
201
|
+
def data(packet, port)
|
202
|
+
if packet.block_no == @block_no
|
203
|
+
stop_timer!
|
204
|
+
if @peer_port.nil?
|
205
|
+
@peer_port = port
|
206
|
+
end
|
207
|
+
if packet.data.size > 0
|
208
|
+
@listener.received_block(packet.data)
|
209
|
+
end
|
210
|
+
if packet.data.size < 512
|
211
|
+
finished!
|
212
|
+
else
|
213
|
+
send_ack(@block_no)
|
214
|
+
@block_no += 1
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
def ack(packet, port)
|
219
|
+
abort!(4, "Received unexpected TFTP ACK packet while receiving file")
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
class ClientReceive < Receive
|
224
|
+
def initialize(connection, peer_addr, listener, filename)
|
225
|
+
super(connection, peer_addr, nil, listener)
|
226
|
+
send_rrq(peer_addr, 69, filename)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
class ServerReceive < Receive
|
231
|
+
def initialize(connection, peer_addr, peer_port, listener)
|
232
|
+
super(connection, peer_addr, peer_port, listener)
|
233
|
+
send_ack(0)
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
class Send < Transfer
|
238
|
+
# Send a file to the peer
|
239
|
+
# Note that the file data does not necessarily have to originate from the filesystem;
|
240
|
+
# it could be cached in memory or generated dynamically
|
241
|
+
def initialize(connection, peer_addr, peer_port, listener, file_data)
|
242
|
+
super(connection, peer_addr, peer_port, listener)
|
243
|
+
@buffer = file_data
|
244
|
+
end
|
245
|
+
|
246
|
+
def ack(packet, port)
|
247
|
+
if packet.block_no == @block_no
|
248
|
+
stop_timer!
|
249
|
+
if @peer_port.nil?
|
250
|
+
@peer_port = nil
|
251
|
+
end
|
252
|
+
@block_no += 1
|
253
|
+
if @buffer.size <= 512
|
254
|
+
@pos = send_block(@buffer, @pos, @block_no)
|
255
|
+
finished!
|
256
|
+
else
|
257
|
+
@pos = send_block(@buffer, @pos, @block_no)
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
def data(packet, port)
|
262
|
+
abort!(4, "Received unexpected TFTP DATA packet while sending file")
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
class ClientSend < Send
|
267
|
+
def initialize(connection, peer_addr, listener, filename, file_data)
|
268
|
+
super(connection, peer_addr, nil, listener, file_data)
|
269
|
+
# we need to send a WRQ and get an ACK before sending
|
270
|
+
@block_no = 0
|
271
|
+
@pos = 0
|
272
|
+
send_wrq(peer_addr, 69, filename)
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
class ServerSend < Send
|
277
|
+
def initialize(connection, peer_addr, peer_port, listener, file_data)
|
278
|
+
super(connection, peer_addr, peer_port, listener, file_data)
|
279
|
+
# we have already received a RRQ, we can send to the peer immediately
|
280
|
+
@block_no = 1
|
281
|
+
@pos = send_block(@buffer, 0, @block_no)
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def self.ListeningConnection(listener_klass)
|
286
|
+
# create a subclass of ListeningConnection which uses a specific type of listener
|
287
|
+
# this is necessary because when opening a socket, EM does not take a connection OBJECT argument, but a connection CLASS
|
288
|
+
Class.new(TFTP::ListeningConnection).tap { |c| c.instance_variable_set(:@listener_klass, listener_klass) }
|
289
|
+
end
|
290
|
+
|
291
|
+
# UDP socket (really EM::Connection) on TFTP server which waits for incoming RRQ/WRQ
|
292
|
+
# When an incoming transfer request arrives, calls get/put on its listener
|
293
|
+
# and starts the transfer if get passes true,<file data> (or put passes true) to the block
|
294
|
+
# If the data must be read from hard disk, EM.defer { File.read(...) } is recommended,
|
295
|
+
# so as not to block the EM reactor from handling events
|
296
|
+
class ListeningConnection < EM::Connection
|
297
|
+
include Protocol
|
298
|
+
|
299
|
+
def listener
|
300
|
+
self.class.instance_eval { @listener_klass }
|
301
|
+
end
|
302
|
+
|
303
|
+
def receive_data(data)
|
304
|
+
peer_port, peer_addr = Socket.unpack_sockaddr_in(get_peername)
|
305
|
+
$stderr.puts "Received: #{data} (#{data[0..3].bytes.to_a.join(',')}) from #{peer_addr}:#{peer_port}" if $DEBUG
|
306
|
+
packet = Packet.new(data.encode!(Encoding::BINARY))
|
307
|
+
send(packet.opcode, peer_addr, peer_port, packet)
|
308
|
+
rescue TFTP::Error
|
309
|
+
if listener.respond_to?(:error)
|
310
|
+
listener.error($!.message)
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
private
|
315
|
+
|
316
|
+
def rrq(addr, port, packet)
|
317
|
+
listener.get(addr, port, packet.filename) do |aye_nay, str_data|
|
318
|
+
if aye_nay
|
319
|
+
connection = EM.open_datagram_socket('0.0.0.0', 0, TFTP::TransferConnection)
|
320
|
+
connection.transfer = ServerSend.new(connection, addr, port, listener.new, str_data)
|
321
|
+
else
|
322
|
+
send_error(addr, port, 0, str_data || "Denied")
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
def wrq(addr, port, packet)
|
328
|
+
listener.put(addr, port, packet.filename) do |aye_nay, str_data|
|
329
|
+
if aye_nay
|
330
|
+
connection = EM.open_datagram_socket('0.0.0.0', 0, TFTP::TransferConnection)
|
331
|
+
connection.transfer = ServerReceive.new(connection, addr, port, listener.new)
|
332
|
+
else
|
333
|
+
send_error(addr, port, 0, "Denied")
|
334
|
+
end
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
def data(addr, port, packet)
|
339
|
+
send_error(addr, port, 5, "Unknown transfer ID")
|
340
|
+
end
|
341
|
+
def ack(addr, port, packet)
|
342
|
+
# It's not a good idea to send an error back for unexpected ACK;
|
343
|
+
# some TFTP clients may send an "eager" ACK to try to make file come faster
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
# UDP socket (really EM::Connection) which is being used for an in-progress transfer
|
348
|
+
class TransferConnection < EM::Connection
|
349
|
+
include Protocol
|
350
|
+
|
351
|
+
attr_accessor :transfer
|
352
|
+
|
353
|
+
def receive_data(data)
|
354
|
+
peer_port, peer_addr = Socket.unpack_sockaddr_in(get_peername)
|
355
|
+
$stderr.puts "Received: #{data} (#{data[0..3].bytes.to_a.join(',')}) from #{peer_addr}:#{peer_port}" if $DEBUG
|
356
|
+
packet = Packet.new(data.encode!(Encoding::BINARY))
|
357
|
+
if transfer && peer_addr == transfer.peer_addr && (peer_port == transfer.peer_port || transfer.peer_port.nil?)
|
358
|
+
transfer.send(packet.opcode, packet, peer_port)
|
359
|
+
end
|
360
|
+
rescue TFTP::Error
|
361
|
+
transfer.error!($!.message)
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
# Listeners
|
366
|
+
|
367
|
+
class ClientUploader
|
368
|
+
def initialize(&block)
|
369
|
+
@callback = block
|
370
|
+
end
|
371
|
+
def completed
|
372
|
+
@callback.call(true, nil)
|
373
|
+
end
|
374
|
+
def failed(error_msg)
|
375
|
+
@callback.call(false, error_msg)
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
class ClientDownloader
|
380
|
+
def initialize(&block)
|
381
|
+
@callback = block
|
382
|
+
@buffer = ""
|
383
|
+
end
|
384
|
+
def received_block(block)
|
385
|
+
@buffer << block
|
386
|
+
end
|
387
|
+
def completed
|
388
|
+
@callback.call(true, @buffer)
|
389
|
+
end
|
390
|
+
def failed(error_msg)
|
391
|
+
@callback.call(false, error_msg)
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
def self.ReadOnlyFileServer(base_dir)
|
396
|
+
Class.new(ReadOnlyFileServer).tap { |c| c.instance_variable_set(:@base_dir, base_dir) }
|
397
|
+
end
|
398
|
+
|
399
|
+
class ReadOnlyFileServer
|
400
|
+
def self.get(addr, port, filename, &block)
|
401
|
+
filename.slice!(0) if filename.start_with?('/')
|
402
|
+
begin
|
403
|
+
path = File.join(@base_dir, filename)
|
404
|
+
if File.exist?(path)
|
405
|
+
EventMachine.defer(proc { File.binread(path) }, proc { |file_data| block.call(true, file_data) })
|
406
|
+
else
|
407
|
+
block.call(false, "File not found")
|
408
|
+
end
|
409
|
+
rescue
|
410
|
+
block.call(false, $!.message)
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def self.put(addr, port, filename)
|
415
|
+
yield false
|
416
|
+
end
|
417
|
+
|
418
|
+
def completed
|
419
|
+
# log?
|
420
|
+
end
|
421
|
+
def failed(error_msg)
|
422
|
+
# log?
|
423
|
+
end
|
424
|
+
|
425
|
+
private
|
426
|
+
|
427
|
+
def base_dir
|
428
|
+
self.class.instance_eval { @base_dir }
|
429
|
+
end
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
module EventMachine
|
434
|
+
class << self
|
435
|
+
def start_tftp_server(server='0.0.0.0', port=69, listener_klass)
|
436
|
+
if !listener_klass.is_a?(Class)
|
437
|
+
if listener_klass.is_a?(Module)
|
438
|
+
listener_klass = Class.new.tap { include listener_klass }
|
439
|
+
else
|
440
|
+
raise ArgumentError, "Expected a class which defines callback methods like #received_block, #completed, and #failed; got #{listener_klass.class} instead"
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
EM.open_datagram_socket(server, port, TFTP::ListeningConnection(listener_klass))
|
445
|
+
end
|
446
|
+
|
447
|
+
def tftp_get(server, port=69, filename, &callback)
|
448
|
+
conn = EM.open_datagram_socket('0.0.0.0', 0, TFTP::TransferConnection)
|
449
|
+
conn.transfer = TFTP::ClientReceive.new(conn, server, TFTP::ClientDownloader.new(&callback), filename)
|
450
|
+
end
|
451
|
+
|
452
|
+
def tftp_put(server, port=69, filename, file_data, &callback)
|
453
|
+
conn = EM.open_datagram_socket('0.0.0.0', 0, TFTP::TransferConnection)
|
454
|
+
if file_data.is_a?(IO)
|
455
|
+
EventMachine.defer(proc { file_data.read }, proc { |data| conn.transfer = TFTP::ClientSend.new(conn, server, port, TFTP::ClientUploader.new(&callback), filename, data) })
|
456
|
+
else
|
457
|
+
conn.transfer = TFTP::ClientSend.new(conn, server, TFTP::ClientUploader.new(&callback), filename, file_data)
|
458
|
+
end
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|
metadata
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: em-tftp
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Alex Dowad
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-06-14 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Ruby EventMachine-based TFTP implementation (both server and client)
|
14
|
+
email: alexinbeijing@gmail.com
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- lib/em-tftp.rb
|
20
|
+
homepage: http://github.com/alexdowad/em-tftp
|
21
|
+
licenses:
|
22
|
+
- None (Public Domain)
|
23
|
+
metadata: {}
|
24
|
+
post_install_message:
|
25
|
+
rdoc_options: []
|
26
|
+
require_paths:
|
27
|
+
- lib
|
28
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
34
|
+
requirements:
|
35
|
+
- - ">="
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
requirements: []
|
39
|
+
rubyforge_project:
|
40
|
+
rubygems_version: 2.2.2
|
41
|
+
signing_key:
|
42
|
+
specification_version: 4
|
43
|
+
summary: Ruby EventMachine TFTP implementation
|
44
|
+
test_files: []
|