tftpplus 0.2
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/ChangeLog +47 -0
- data/README +21 -0
- data/bin/tftp_client.rb +112 -0
- data/lib/net/tftp+.rb +796 -0
- data/test/test.rb +58 -0
- metadata +49 -0
data/ChangeLog
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
2006-12-08 msoulier
|
2
|
+
* Fixed handling of remote TID.
|
3
|
+
* Added tar task to Rakefile.
|
4
|
+
|
5
|
+
2006-10-10 msoulier
|
6
|
+
* Added site, and Rakefile entry to push site.
|
7
|
+
|
8
|
+
2006-10-10 01:46 msoulier
|
9
|
+
|
10
|
+
* trunk/ChangeLog, trunk/README, trunk/doc,
|
11
|
+
trunk/lib/net/tftp+.rb: Adding Rakefile
|
12
|
+
|
13
|
+
2006-10-06 01:35 msoulier
|
14
|
+
|
15
|
+
* trunk/lib/net/tftp+.rb: Optimizing Tftp object dispatch
|
16
|
+
|
17
|
+
2006-10-04 01:27 msoulier
|
18
|
+
|
19
|
+
* trunk/lib/net/tftp+.rb: minor method rename
|
20
|
+
|
21
|
+
2006-09-25 02:11 msoulier
|
22
|
+
|
23
|
+
* trunk/lib/net/tftp+.rb: Added timeouts on recvfrom
|
24
|
+
|
25
|
+
2006-09-24 02:39 msoulier
|
26
|
+
|
27
|
+
* trunk/lib/net/tftp+.rb: Updated debugging output
|
28
|
+
|
29
|
+
2006-09-24 02:31 msoulier
|
30
|
+
|
31
|
+
* trunk/lib/net/tftp+.rb, trunk/share, trunk/share/tftp_client.rb,
|
32
|
+
trunk/test/test.rb: Moved client code into its own sample client.
|
33
|
+
|
34
|
+
2006-09-22 16:48 msoulier
|
35
|
+
|
36
|
+
* trunk/lib/net/tftp+.rb: Pulling hardcoded test
|
37
|
+
|
38
|
+
2006-09-22 16:44 msoulier
|
39
|
+
|
40
|
+
* doc, lib, test, trunk/doc, trunk/lib, trunk/test: Setting up
|
41
|
+
branch structure.
|
42
|
+
|
43
|
+
2006-09-22 16:42 msoulier
|
44
|
+
|
45
|
+
* doc, doc/rfc1350.txt, doc/rfc2347.txt, doc/rfc2348.txt, lib,
|
46
|
+
lib/net, lib/net/tftp+.rb, test, test/test.rb: Initial import.
|
47
|
+
|
data/README
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
About Tftpplus:
|
2
|
+
===============
|
3
|
+
|
4
|
+
A new tftp library for clients and servers that supports RFCs 1350, 2347 and
|
5
|
+
2348 (ie. variable block sizes).
|
6
|
+
|
7
|
+
Release Notes:
|
8
|
+
==============
|
9
|
+
|
10
|
+
About version 0.2:
|
11
|
+
==================
|
12
|
+
|
13
|
+
- Fixed handling of remote TID.
|
14
|
+
|
15
|
+
About version 0.1:
|
16
|
+
==================
|
17
|
+
|
18
|
+
- Added timeouts on recvfrom
|
19
|
+
- Updated debugging output
|
20
|
+
- Moved client code into its own sample client.
|
21
|
+
- Client functioning with support for variable block sizes.
|
data/bin/tftp_client.rb
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$:.unshift File.join(File.dirname(__FILE__), "..", "lib")
|
4
|
+
require 'net/tftp+'
|
5
|
+
require 'optparse'
|
6
|
+
require 'ostruct'
|
7
|
+
require 'logger'
|
8
|
+
|
9
|
+
LogLevel = Logger::INFO
|
10
|
+
|
11
|
+
$tftplog = Logger.new($stderr)
|
12
|
+
$tftplog.level = LogLevel
|
13
|
+
$log = $tftplog
|
14
|
+
|
15
|
+
def parse_args
|
16
|
+
# Set up defaults
|
17
|
+
options = OpenStruct.new
|
18
|
+
options.filename = nil
|
19
|
+
options.host = nil
|
20
|
+
options.debug = false
|
21
|
+
options.blksize = 512
|
22
|
+
options.port = 69
|
23
|
+
|
24
|
+
banner =<<EOF
|
25
|
+
Usage: tftp_client <options>
|
26
|
+
EOF
|
27
|
+
opts = nil
|
28
|
+
begin
|
29
|
+
$log.debug("client") { "Parsing command line arguments" }
|
30
|
+
opts = OptionParser.new do |opts|
|
31
|
+
opts.banner = banner
|
32
|
+
|
33
|
+
opts.on('-f', '--filename=MANDATORY', 'Remote filename') do |f|
|
34
|
+
options.filename = f
|
35
|
+
$log.debug('client') { "filename is #{f}" }
|
36
|
+
end
|
37
|
+
opts.on('-h', '--host=MANDATORY', 'Remote host or IP address') do |h|
|
38
|
+
options.host = h
|
39
|
+
$log.debug('client') { "host is #{h}" }
|
40
|
+
end
|
41
|
+
opts.on('-p', '--port=', 'Remote port to use (default: 69)') do |p|
|
42
|
+
options.port = p.to_i
|
43
|
+
$log.debug('client') { "port is #{p}" }
|
44
|
+
end
|
45
|
+
opts.on('-d', '--debug', 'Debugging output on') do |d|
|
46
|
+
options.debug = d
|
47
|
+
$log.level = Logger::DEBUG
|
48
|
+
$log.debug('client') { "Debug output requested" }
|
49
|
+
end
|
50
|
+
opts.on('-b', '--blksize=', 'Blocksize option: 8-65536 bytes') do |b|
|
51
|
+
options.blksize = b.to_i
|
52
|
+
$log.debug('client') { "blksize is #{b}" }
|
53
|
+
end
|
54
|
+
opts.on_tail('-h', '--help', 'Show this message') do
|
55
|
+
puts opts
|
56
|
+
exit
|
57
|
+
end
|
58
|
+
end.parse!
|
59
|
+
|
60
|
+
unless options.filename and options.host
|
61
|
+
raise OptionParser::InvalidOption,
|
62
|
+
"Both --host and --filename are required"
|
63
|
+
end
|
64
|
+
#unless options.blksize =~ /^\d+/
|
65
|
+
# raise OptionParser::InvalidOption,
|
66
|
+
# "blksize must be an integer"
|
67
|
+
#end
|
68
|
+
unless options.blksize >= 8 and options.blksize <= 65536
|
69
|
+
raise OptionParser::InvalidOption,
|
70
|
+
"blksize can only be between 8 and 65536 bytes"
|
71
|
+
end
|
72
|
+
unless options.port > 0 and options.port < 65537
|
73
|
+
raise OptionParser::InvalidOption,
|
74
|
+
"port must be positive integer between 1 and 65536"
|
75
|
+
end
|
76
|
+
rescue Exception => details
|
77
|
+
$stderr.puts details.to_s
|
78
|
+
$stderr.puts opts
|
79
|
+
exit 1
|
80
|
+
end
|
81
|
+
|
82
|
+
return options
|
83
|
+
end
|
84
|
+
|
85
|
+
def main
|
86
|
+
options = parse_args
|
87
|
+
|
88
|
+
size = 0
|
89
|
+
start = Time.now
|
90
|
+
$log.info('client') { "Starting download of #{options.filename} from #{options.host}" }
|
91
|
+
$log.info('client') { "Options: blksize = #{options.blksize}" }
|
92
|
+
|
93
|
+
client = TftpClient.new(options.host, options.port)
|
94
|
+
tftp_opts = { :blksize => options.blksize.to_i }
|
95
|
+
client.download(options.filename, options.filename, tftp_opts) do |pkt|
|
96
|
+
size += pkt.data.length
|
97
|
+
$log.debug('client') { "Downloaded #{size} bytes" }
|
98
|
+
end
|
99
|
+
|
100
|
+
finish = Time.now
|
101
|
+
duration = finish - start
|
102
|
+
|
103
|
+
$log.info('client') { "" }
|
104
|
+
$log.info('client') { "Started: #{start}" }
|
105
|
+
$log.info('client') { "Finished: #{finish}" }
|
106
|
+
$log.info('client') { "Duration: #{duration}" }
|
107
|
+
$log.info('client') { "Downloaded #{size} bytes in #{duration} seconds" }
|
108
|
+
$log.info('client') { "Throughput: #{(size/duration)*8} bps" }
|
109
|
+
$log.info('client') { " #{(size/duration)*8 / 1024} kbps" }
|
110
|
+
end
|
111
|
+
|
112
|
+
main
|
data/lib/net/tftp+.rb
ADDED
@@ -0,0 +1,796 @@
|
|
1
|
+
# A Ruby library for Trivial File Transfer Protocol.
|
2
|
+
# Supports the following RFCs
|
3
|
+
# RFC 1350 - THE TFTP PROTOCOL (REVISION 2)
|
4
|
+
# RFC 2347 - TFTP Option Extension
|
5
|
+
# RFC 2348 - TFTP Blocksize Option
|
6
|
+
# Currently, the TftpServer class is not functional.
|
7
|
+
# The TftpClient class works fine. Let me know if this is not true.
|
8
|
+
|
9
|
+
require 'socket'
|
10
|
+
require 'timeout'
|
11
|
+
require 'resolv'
|
12
|
+
|
13
|
+
# Todo
|
14
|
+
# - properly handle decoding of options ala rfc 2347
|
15
|
+
# - use maxdups
|
16
|
+
# - use socket timeouts
|
17
|
+
# - implement variable block-sizes
|
18
|
+
|
19
|
+
MinBlkSize = 8
|
20
|
+
DefBlkSize = 512
|
21
|
+
MaxBlkSize = 65536
|
22
|
+
SockTimeout = 5
|
23
|
+
MaxDups = 20
|
24
|
+
Assertions = true
|
25
|
+
MaxRetry = 5
|
26
|
+
|
27
|
+
# This class is a Nil logging device. It catches all of the logger calls and
|
28
|
+
# does nothing with them. It is up to the client to provide a real logger
|
29
|
+
# and assign it to $tftplog.
|
30
|
+
class TftpNilLogger
|
31
|
+
def method_missing(*args)
|
32
|
+
# do nothing
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# This global is the logger used by the library. By default it is an instance
|
37
|
+
# of TftpNilLogger, which does nothing. Replace it with a logger if you want
|
38
|
+
# one.
|
39
|
+
$tftplog = TftpNilLogger.new
|
40
|
+
|
41
|
+
class TftpError < RuntimeError
|
42
|
+
end
|
43
|
+
|
44
|
+
# This function is a custom assertion in the library to catch unsupported
|
45
|
+
# states and types. If the assertion fails, msg is raised in a TftpError
|
46
|
+
# exception.
|
47
|
+
def tftpassert(msg, &code)
|
48
|
+
if not code.call and Assertions
|
49
|
+
raise TftpError, "Assertion Failed: #{msg}", caller
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# This class is the root of all TftpPacket classes in the library. It should
|
54
|
+
# not be instantiated directly. It exists to provide code sharing to the child
|
55
|
+
# classes.
|
56
|
+
class TftpPacket
|
57
|
+
attr_accessor :opcode, :buffer
|
58
|
+
|
59
|
+
# Class constructor. This class and its children take no parameters. A
|
60
|
+
# client is expected to set instance variables after instantiation.
|
61
|
+
def initialize
|
62
|
+
@opcode = 0
|
63
|
+
@buffer = nil
|
64
|
+
@options = {}
|
65
|
+
end
|
66
|
+
|
67
|
+
# Abstract method, must be implemented in all child classes.
|
68
|
+
def encode
|
69
|
+
raise NotImplementedError
|
70
|
+
end
|
71
|
+
|
72
|
+
# Abstract method, must be implemented in all child classes.
|
73
|
+
def decode
|
74
|
+
raise NotImplementedError
|
75
|
+
end
|
76
|
+
|
77
|
+
# This is a setter for the options hash. It ensures that the keys are
|
78
|
+
# Symbols and that the values are strings. You can pass in a non-String
|
79
|
+
# value as long as the .to_s method returns a good value.
|
80
|
+
def options=(opts)
|
81
|
+
myopts = {}
|
82
|
+
opts.each do |key, val|
|
83
|
+
$tftplog.debug('tftp+') { "looping on key #{key}, val #{val}" }
|
84
|
+
$tftplog.debug('tftp+') { "class of key is #{key.class}" }
|
85
|
+
tftpassert("options keys must be symbols") { key.class == Symbol }
|
86
|
+
myopts[key.to_s] = val.to_s
|
87
|
+
end
|
88
|
+
@options = myopts
|
89
|
+
end
|
90
|
+
|
91
|
+
# A getter for the options hash.
|
92
|
+
def options
|
93
|
+
return @options
|
94
|
+
end
|
95
|
+
|
96
|
+
protected
|
97
|
+
|
98
|
+
# This method takes the portion of the buffer containing the options and
|
99
|
+
# decodes it, returning a hash of the option name/value pairs, with the
|
100
|
+
# keys as Symbols and the values as Strings.
|
101
|
+
def decode_options(buffer)
|
102
|
+
# We need to variably decode the buffer. The buffer here is only that
|
103
|
+
# part of the original buffer containing options. We will decode the
|
104
|
+
# options here and return an options array.
|
105
|
+
nulls = 0
|
106
|
+
format = ""
|
107
|
+
# Count the nulls in the buffer, each one terminates a string.
|
108
|
+
buffer.collect do |c|
|
109
|
+
if c.to_i == 0
|
110
|
+
format += "Z*Z*"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
struct = buffer.unpack(format)
|
114
|
+
|
115
|
+
unless struct.length % 2 == 0
|
116
|
+
raise TftpError, "packet with odd number of option/value pairs"
|
117
|
+
end
|
118
|
+
|
119
|
+
while not struct.empty?
|
120
|
+
name = struct.shift
|
121
|
+
value = struct.shift
|
122
|
+
options[name.to_sym] = value
|
123
|
+
$tftplog.debug('tftp+') { "decoded option #{name} with value #{value}" }
|
124
|
+
end
|
125
|
+
return options
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# This class is a parent class for the RRQ and WRQ packets, as they share a
|
130
|
+
# lot of code.
|
131
|
+
# 2 bytes string 1 byte string 1 byte
|
132
|
+
# -----------------------------------------------
|
133
|
+
# RRQ/ | 01/02 | Filename | 0 | Mode | 0 |
|
134
|
+
# WRQ -----------------------------------------------
|
135
|
+
# +-------+---~~---+---+---~~---+---+---~~---+---+---~~---+---+
|
136
|
+
# | opc |filename| 0 | mode | 0 | blksize| 0 | #octets| 0 |
|
137
|
+
# +-------+---~~---+---+---~~---+---+---~~---+---+---~~---+---+
|
138
|
+
class TftpPacketInitial < TftpPacket
|
139
|
+
attr_accessor :filename, :mode
|
140
|
+
|
141
|
+
def initialize
|
142
|
+
super()
|
143
|
+
@filename = nil
|
144
|
+
@mode = nil
|
145
|
+
end
|
146
|
+
|
147
|
+
# Encode of the packet based on the instance variables. Both the filename
|
148
|
+
# and mode instance variables must be set or an exception will be thrown.
|
149
|
+
def encode
|
150
|
+
unless @opcode and @filename and @mode
|
151
|
+
raise ArgumentError, "Required arguments missing."
|
152
|
+
end
|
153
|
+
|
154
|
+
datalist = []
|
155
|
+
|
156
|
+
format = "n"
|
157
|
+
format += "a#{@filename.length}x"
|
158
|
+
datalist.push @opcode
|
159
|
+
datalist.push @filename
|
160
|
+
|
161
|
+
case @mode
|
162
|
+
when "octet"
|
163
|
+
format += "a5"
|
164
|
+
else
|
165
|
+
raise ArgumentError, "Unsupported mode: #{kwargs[:mode]}"
|
166
|
+
end
|
167
|
+
datalist.push @mode
|
168
|
+
|
169
|
+
format += "x"
|
170
|
+
|
171
|
+
@options.each do |key, value|
|
172
|
+
format += "a#{key.length}x"
|
173
|
+
format += "a#{value.length}x"
|
174
|
+
datalist.push key
|
175
|
+
datalist.push value
|
176
|
+
end
|
177
|
+
|
178
|
+
@buffer = datalist.pack(format)
|
179
|
+
return self
|
180
|
+
end
|
181
|
+
|
182
|
+
# Decode the packet based on the contents of the buffer instance variable.
|
183
|
+
# It populates the filename and mode instance variables.
|
184
|
+
def decode
|
185
|
+
unless @buffer
|
186
|
+
raise ArgumentError, "Can't decode, buffer is empty."
|
187
|
+
end
|
188
|
+
struct = @buffer.unpack("nZ*Z*")
|
189
|
+
unless struct[0] == 1 or struct[0] == 2
|
190
|
+
raise TftpError, "opcode #{struct[0]} is not a RRQ or WRQ!"
|
191
|
+
end
|
192
|
+
@filename = struct[1]
|
193
|
+
unless @filename.length > 0
|
194
|
+
raise TftpError, "filename is the null string"
|
195
|
+
end
|
196
|
+
@mode = struct[2]
|
197
|
+
unless valid_mode? @mode
|
198
|
+
raise TftpError, "mode #{@mode} is not valid"
|
199
|
+
end
|
200
|
+
|
201
|
+
# We need to find the point at which the opcode, filename and mode
|
202
|
+
# have ended and the options begin.
|
203
|
+
offset = 0
|
204
|
+
nulls = []
|
205
|
+
@buffer.each_byte do |c|
|
206
|
+
nulls.push offset if c == 0
|
207
|
+
offset += 1
|
208
|
+
end
|
209
|
+
# There should be at least 3, the 0 in the opcode, the terminator for
|
210
|
+
# the filename, and the terminator for the mode. If there are more,
|
211
|
+
# then there are options.
|
212
|
+
if nulls.length < 3
|
213
|
+
raise TftpError, "Failed to parse nulls looking for options"
|
214
|
+
elsif nulls.length > 3
|
215
|
+
lower_bound = nulls[2] + 1
|
216
|
+
@options = decode_options(@buffer[lower_bound..-1])
|
217
|
+
end
|
218
|
+
|
219
|
+
return self
|
220
|
+
end
|
221
|
+
|
222
|
+
protected
|
223
|
+
|
224
|
+
# This method is a boolean validator that returns true if the blocksize
|
225
|
+
# passed is valid, and false otherwise.
|
226
|
+
def valid_blocksize?(blksize)
|
227
|
+
blksize = blksize.to_i
|
228
|
+
if blksize >= 8 and blksize <= 65464
|
229
|
+
return true
|
230
|
+
else
|
231
|
+
return false
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
# This method is a boolean validator that returns true of the mode passed
|
236
|
+
# is valid, and false otherwise. The modes of 'netascii', 'octet' and
|
237
|
+
# 'mail' are valid, even though only 'octet' is currently implemented.
|
238
|
+
def valid_mode?(mode)
|
239
|
+
case mode
|
240
|
+
when "netascii", "octet", "mail"
|
241
|
+
return true
|
242
|
+
else
|
243
|
+
return false
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
# The RRQ packet to request a download.
|
249
|
+
class TftpPacketRRQ < TftpPacketInitial
|
250
|
+
def initialize
|
251
|
+
super()
|
252
|
+
@opcode = 1
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# The WRQ packet to request an upload.
|
257
|
+
class TftpPacketWRQ < TftpPacketInitial
|
258
|
+
def initialize
|
259
|
+
super()
|
260
|
+
@opcode = 2
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# 2 bytes 2 bytes n bytes
|
265
|
+
# ---------------------------------
|
266
|
+
# DATA | 03 | Block # | Data |
|
267
|
+
# ---------------------------------
|
268
|
+
class TftpPacketDAT < TftpPacket
|
269
|
+
attr_accessor :data, :buffer, :blocknumber
|
270
|
+
|
271
|
+
def initialize
|
272
|
+
super()
|
273
|
+
@opcode = 3
|
274
|
+
@blocknumber = 0
|
275
|
+
@data = nil
|
276
|
+
@buffer = nil
|
277
|
+
end
|
278
|
+
|
279
|
+
def encode
|
280
|
+
unless @opcode and @blocknumber and @data
|
281
|
+
raise ArgumentError, "Required fields missing!"
|
282
|
+
end
|
283
|
+
# FIXME - check block size
|
284
|
+
#@buffer = [@opcode, @blocknumber, @data].pack('nnC#{@data.length}')
|
285
|
+
@buffer = [@opcode, @blocknumber].pack('nn')
|
286
|
+
@buffer += @data
|
287
|
+
return self
|
288
|
+
end
|
289
|
+
|
290
|
+
def decode
|
291
|
+
unless @buffer
|
292
|
+
raise ArgumentError, "Can't decode, buffer is empty."
|
293
|
+
end
|
294
|
+
struct = @buffer[0..3].unpack('nn')
|
295
|
+
unless struct[0] == 3
|
296
|
+
raise ArgumentError, "opcode #{struct[0]} is not a DAT!"
|
297
|
+
end
|
298
|
+
@blocknumber = struct[1]
|
299
|
+
@data = @buffer[4..-1]
|
300
|
+
return self
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
# 2 bytes 2 bytes
|
305
|
+
# -------------------
|
306
|
+
# ACK | 04 | Block # |
|
307
|
+
# --------------------
|
308
|
+
class TftpPacketACK < TftpPacket
|
309
|
+
attr_accessor :blocknumber, :buffer
|
310
|
+
|
311
|
+
def initialize
|
312
|
+
super()
|
313
|
+
@opcode = 4
|
314
|
+
@blocknumber = 0
|
315
|
+
@buffer = nil
|
316
|
+
end
|
317
|
+
|
318
|
+
def encode
|
319
|
+
unless @blocknumber
|
320
|
+
raise ArgumentError, "blocknumber required"
|
321
|
+
end
|
322
|
+
@buffer = [@opcode, @blocknumber].pack('nn')
|
323
|
+
return self
|
324
|
+
end
|
325
|
+
|
326
|
+
def decode
|
327
|
+
unless @buffer
|
328
|
+
raise ArgumentError, "Can't decode, buffer is empty."
|
329
|
+
end
|
330
|
+
struct = @buffer.unpack('nn')
|
331
|
+
unless struct[0] == 4
|
332
|
+
raise ArgumentError, "opcode #{struct[0]} is not an ACK!"
|
333
|
+
end
|
334
|
+
@blocknumber = struct[1]
|
335
|
+
return self
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
# 2 bytes 2 bytes string 1 byte
|
340
|
+
# ----------------------------------------
|
341
|
+
# ERROR | 05 | ErrorCode | ErrMsg | 0 |
|
342
|
+
# ----------------------------------------
|
343
|
+
# Error Codes
|
344
|
+
#
|
345
|
+
# Value Meaning
|
346
|
+
#
|
347
|
+
# 0 Not defined, see error message (if any).
|
348
|
+
# 1 File not found.
|
349
|
+
# 2 Access violation.
|
350
|
+
# 3 Disk full or allocation exceeded.
|
351
|
+
# 4 Illegal TFTP operation.
|
352
|
+
# 5 Unknown transfer ID.
|
353
|
+
# 6 File already exists.
|
354
|
+
# 7 No such user.
|
355
|
+
# 8 Failed negotiation
|
356
|
+
class TftpPacketERR < TftpPacket
|
357
|
+
attr_reader :extended_errmsg
|
358
|
+
attr_accessor :errorcode, :errmsg, :buffer
|
359
|
+
ErrMsgs = [
|
360
|
+
'Not defined, see error message (if any).',
|
361
|
+
'File not found.',
|
362
|
+
'Access violation.',
|
363
|
+
'Disk full or allocation exceeded.',
|
364
|
+
'Illegal TFTP operation.',
|
365
|
+
'Unknown transfer ID.',
|
366
|
+
'File already exists.',
|
367
|
+
'No such user.',
|
368
|
+
'Failed negotiation.'
|
369
|
+
]
|
370
|
+
|
371
|
+
def initialize
|
372
|
+
super()
|
373
|
+
@opcode = 5
|
374
|
+
@errorcode = 0
|
375
|
+
@errmsg = nil
|
376
|
+
@extended_errmsg = nil
|
377
|
+
@buffer = nil
|
378
|
+
end
|
379
|
+
|
380
|
+
def encode
|
381
|
+
unless @opcode and @errorcode
|
382
|
+
raise ArgumentError, "Required params missing."
|
383
|
+
end
|
384
|
+
@errmsg = ErrMsgs[@errorcode] unless @errmsg
|
385
|
+
format = 'nn' + "a#{@errmsg.length}" + 'x'
|
386
|
+
@buffer = [@opcode, @errorcode, @errmsg].pack(format)
|
387
|
+
return self
|
388
|
+
end
|
389
|
+
|
390
|
+
def decode
|
391
|
+
unless @buffer
|
392
|
+
raise ArgumentError, "Can't decode, buffer is empty."
|
393
|
+
end
|
394
|
+
struct = @buffer.unpack("nnZ*")
|
395
|
+
unless struct[0] == 5
|
396
|
+
raise ArgumentError, "opcode #{struct[0]} is not an ERR"
|
397
|
+
end
|
398
|
+
@errorcode = struct[1]
|
399
|
+
@errmsg = struct[2]
|
400
|
+
@extended_errmsg = ErrMsgs[@errorcode]
|
401
|
+
return self
|
402
|
+
end
|
403
|
+
|
404
|
+
end
|
405
|
+
|
406
|
+
# +-------+---~~---+---+---~~---+---+---~~---+---+---~~---+---+
|
407
|
+
# | opc | opt1 | 0 | value1 | 0 | optN | 0 | valueN | 0 |
|
408
|
+
# +-------+---~~---+---+---~~---+---+---~~---+---+---~~---+---+
|
409
|
+
class TftpPacketOACK < TftpPacket
|
410
|
+
def initialize
|
411
|
+
super()
|
412
|
+
@opcode = 6
|
413
|
+
end
|
414
|
+
|
415
|
+
def encode
|
416
|
+
datalist = [@opcode]
|
417
|
+
format = 'n'
|
418
|
+
options.each do |key, val|
|
419
|
+
format += "a#{key.to_s.length}x"
|
420
|
+
format += "a#{val.to_s.length}x"
|
421
|
+
datalist.push key
|
422
|
+
datalist.push val
|
423
|
+
end
|
424
|
+
@buffer = datalist.pack(format)
|
425
|
+
return self
|
426
|
+
end
|
427
|
+
|
428
|
+
def decode
|
429
|
+
opcode = @buffer[0..1].unpack('n')[0]
|
430
|
+
unless opcode == @opcode
|
431
|
+
raise ArgumentError, "opcode #{opcode} is not an OACK"
|
432
|
+
end
|
433
|
+
|
434
|
+
@options = decode_options(@buffer[2..-1])
|
435
|
+
return self
|
436
|
+
end
|
437
|
+
end
|
438
|
+
|
439
|
+
class TftpPacketFactory
|
440
|
+
def initialize
|
441
|
+
end
|
442
|
+
|
443
|
+
def create(opcode)
|
444
|
+
return case opcode
|
445
|
+
when 1 then TftpPacketRRQ
|
446
|
+
when 2 then TftpPacketWRQ
|
447
|
+
when 3 then TftpPacketDAT
|
448
|
+
when 4 then TftpPacketACK
|
449
|
+
when 5 then TftpPacketERR
|
450
|
+
when 6 then TftpPacketOACK
|
451
|
+
else raise ArgumentError, "Unsupported opcode: #{opcode}"
|
452
|
+
end.new
|
453
|
+
end
|
454
|
+
|
455
|
+
def parse(buffer)
|
456
|
+
unless buffer
|
457
|
+
raise ArgumentError, "buffer cannot be empty"
|
458
|
+
end
|
459
|
+
opcode = buffer[0..1].unpack('n')[0]
|
460
|
+
packet = create(opcode)
|
461
|
+
packet.buffer = buffer
|
462
|
+
packet.decode
|
463
|
+
return packet
|
464
|
+
end
|
465
|
+
end
|
466
|
+
|
467
|
+
class TftpSession
|
468
|
+
attr_accessor :options, :state
|
469
|
+
attr_reader :dups, :errors
|
470
|
+
|
471
|
+
def initialize
|
472
|
+
# Agreed upon session options
|
473
|
+
@options = {}
|
474
|
+
# State of the session, can be one of
|
475
|
+
# nil - No state yet
|
476
|
+
# :rrq - Just sent rrq, waiting for response
|
477
|
+
# :wrq - Just sent wrq, waiting for response
|
478
|
+
# :dat - transferring data
|
479
|
+
# :oack - Received oack, negotiating options
|
480
|
+
# :ack - Acknowledged oack, waiting for response
|
481
|
+
# :err - Fatal problems, giving up
|
482
|
+
# :done - Session is over, file transferred
|
483
|
+
@state = nil
|
484
|
+
@dups = 0
|
485
|
+
@errors = 0
|
486
|
+
@blksize = DefBlkSize
|
487
|
+
end
|
488
|
+
end
|
489
|
+
|
490
|
+
class TftpServer < TftpSession
|
491
|
+
def initialize
|
492
|
+
super()
|
493
|
+
@iface = nil
|
494
|
+
@port = nil
|
495
|
+
@root = nil
|
496
|
+
@sessions = []
|
497
|
+
end
|
498
|
+
|
499
|
+
# This method starts a server listening on a given port, to serve up files
|
500
|
+
# at a given path. It takes an optional ip to bind to, which defaults to
|
501
|
+
# localhost (127.0.0.1).
|
502
|
+
def listen(port, path, iface="127.0.0.1")
|
503
|
+
@iface = iface
|
504
|
+
@port = port
|
505
|
+
@root = path
|
506
|
+
sock = UDPSocket.new
|
507
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
508
|
+
#sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, SockTimeout)
|
509
|
+
sock.bind(iface, port)
|
510
|
+
$tftplog.info('tftp+') { "Bound to #{iface} on port #{port}" }
|
511
|
+
|
512
|
+
factory = TftpPacketFactory.new
|
513
|
+
retry_count = 0
|
514
|
+
loop do
|
515
|
+
$tftplog.debug('tftp+') { "Waiting for incoming datagram..." }
|
516
|
+
msg = sender = nil
|
517
|
+
begin
|
518
|
+
status = Timeout::timeout(SockTimeout) {
|
519
|
+
msg, sender = sock.recvfrom(MaxBlkSize)
|
520
|
+
}
|
521
|
+
rescue Timeout::Error => details
|
522
|
+
retry_count += 1
|
523
|
+
if retry_count > MaxRetry
|
524
|
+
msg = "Timeout! Max retries exceeded. Giving up."
|
525
|
+
$tftplog.error('tftp+') { msg }
|
526
|
+
raise TftpError, msg
|
527
|
+
else
|
528
|
+
$tftplog.warn('tftp+') { "Timeout! Lets try again." }
|
529
|
+
next
|
530
|
+
end
|
531
|
+
end
|
532
|
+
prot, rport, rhost, rip = sender
|
533
|
+
|
534
|
+
pkt = factory.parse(msg)
|
535
|
+
$tftplog.debug('tftp+') { "pkt is #{pkt}" }
|
536
|
+
|
537
|
+
key = "#{rip}-#{rport}"
|
538
|
+
handler = nil
|
539
|
+
unless @sessions.has_key? key
|
540
|
+
handler = TftpServerHandler.new(rhost, rport, key, root)
|
541
|
+
else
|
542
|
+
handler = @sessions[key]
|
543
|
+
end
|
544
|
+
handler.handle(pkt)
|
545
|
+
end
|
546
|
+
end
|
547
|
+
end
|
548
|
+
|
549
|
+
class TftpServerHandler < TftpSession
|
550
|
+
def initialize(rhost, rport, key, root)
|
551
|
+
super()
|
552
|
+
@host = rhost
|
553
|
+
@port = rport
|
554
|
+
@key = key
|
555
|
+
@root = root
|
556
|
+
end
|
557
|
+
|
558
|
+
def handle(pkt)
|
559
|
+
end
|
560
|
+
end
|
561
|
+
|
562
|
+
class TftpClient < TftpSession
|
563
|
+
attr_reader :host, :port
|
564
|
+
|
565
|
+
def initialize(host, port)
|
566
|
+
super()
|
567
|
+
@host = host
|
568
|
+
# Force the port to a string type.
|
569
|
+
@iport = port.to_s
|
570
|
+
@port = nil
|
571
|
+
# FIXME - move host and port args to download method
|
572
|
+
|
573
|
+
begin
|
574
|
+
@address = Resolv::IPv4.create(@host)
|
575
|
+
rescue ArgumentError => details
|
576
|
+
# So, @host doesn't look like an IP. Resolve it.
|
577
|
+
# A Resolv::ResolvError exception could be raised here, let it
|
578
|
+
# filter up.
|
579
|
+
@address = Resolv::DNS.new.getaddress(@host)
|
580
|
+
end
|
581
|
+
end
|
582
|
+
|
583
|
+
# FIXME - this method is too big
|
584
|
+
def download(filename, output, options={})
|
585
|
+
@blksize = options[:blksize] if options.has_key? :blksize
|
586
|
+
$tftplog.debug('tftp+') { "Opening output file #{output}" }
|
587
|
+
fout = File.open(output, "w")
|
588
|
+
sock = UDPSocket.new
|
589
|
+
|
590
|
+
pkt = TftpPacketRRQ.new
|
591
|
+
pkt.filename = filename
|
592
|
+
pkt.mode = 'octet' # FIXME - shouldn't hardcode this
|
593
|
+
pkt.options = options
|
594
|
+
$tftplog.info('tftp+') { "Sending download request for #{filename}" }
|
595
|
+
$tftplog.info('tftp+') { "host = #{@host}, port = #{@iport}" }
|
596
|
+
sock.send(pkt.encode.buffer, 0, @host, @iport)
|
597
|
+
@state = :rrq
|
598
|
+
|
599
|
+
factory = TftpPacketFactory.new
|
600
|
+
|
601
|
+
blocknumber = 1
|
602
|
+
retry_count = 0
|
603
|
+
loop do
|
604
|
+
$tftplog.debug('tftp+') { "Waiting for incoming datagram..." }
|
605
|
+
msg = sender = nil
|
606
|
+
begin
|
607
|
+
status = Timeout::timeout(SockTimeout) {
|
608
|
+
msg, sender = sock.recvfrom(MaxBlkSize)
|
609
|
+
}
|
610
|
+
rescue Timeout::Error => details
|
611
|
+
retry_count += 1
|
612
|
+
if retry_count > MaxRetry
|
613
|
+
msg = "Timeout! Max retries exceeded. Giving up."
|
614
|
+
$tftplog.error('tftp+') { msg }
|
615
|
+
raise TftpError, msg
|
616
|
+
else
|
617
|
+
$tftplog.debug('tftp+') { "Timeout! Lets try again." }
|
618
|
+
next
|
619
|
+
end
|
620
|
+
end
|
621
|
+
prot, rport, rhost, rip = sender
|
622
|
+
$tftplog.info('tftp+') { "Received #{msg.length} byte packet" }
|
623
|
+
$tftplog.debug('tftp+') { "Remote port is #{rport} and remote host is #{rhost}" }
|
624
|
+
|
625
|
+
if @address.to_s != rip
|
626
|
+
# Skip it
|
627
|
+
@errors += 1
|
628
|
+
$stderr.write "It is a rogue packet! #{sender[1]} #{sender[2]}\n"
|
629
|
+
next
|
630
|
+
elsif @port and @port != rport.to_s
|
631
|
+
# Skip it
|
632
|
+
@errors += 1
|
633
|
+
$stderr.write "It is a rogue packet! #{sender[1]} #{sender[2]}\n"
|
634
|
+
next
|
635
|
+
else not @port
|
636
|
+
# Set this as our TID
|
637
|
+
$tftplog.info('tftp+') { "Set remote TID to #{@port}" }
|
638
|
+
@port = rport.to_s
|
639
|
+
end
|
640
|
+
|
641
|
+
pkt = factory.parse(msg)
|
642
|
+
$tftplog.debug('tftp+') { "pkt is #{pkt}" }
|
643
|
+
|
644
|
+
# FIXME - Refactor this into separate methods to handle each case.
|
645
|
+
if pkt.is_a? TftpPacketRRQ
|
646
|
+
# Skip it, but info('tftp+')rm the sender.
|
647
|
+
err = TftpPacketERR.new
|
648
|
+
err.errorcode = 4 # illegal op
|
649
|
+
sock.send(err.encode.buffer, 0, @host, @port)
|
650
|
+
@errors += 1
|
651
|
+
$stderr.write "It is a RRQ packet in download, state #{@state}\n"
|
652
|
+
|
653
|
+
elsif pkt.is_a? TftpPacketWRQ
|
654
|
+
# Skip it, but info('tftp+')rm the sender.
|
655
|
+
err = TftpPacketERR.new
|
656
|
+
err.errorcode = 4 # illegal op
|
657
|
+
sock.send(err.encode.buffer, 0, @host, @port)
|
658
|
+
@errors += 1
|
659
|
+
$stderr.write "It is a WRQ packet in download, state #{@state}\n"
|
660
|
+
|
661
|
+
elsif pkt.is_a? TftpPacketACK
|
662
|
+
# Skip it, but info('tftp+')rm the sender.
|
663
|
+
err = TftpPacketERR.new
|
664
|
+
err.errorcode = 4 # illegal op
|
665
|
+
sock.send(err.encode.buffer, 0, @host, @port)
|
666
|
+
@errors += 1
|
667
|
+
$stderr.write "It is a ACK packet in download, state #{@state}\n"
|
668
|
+
|
669
|
+
elsif pkt.is_a? TftpPacketERR
|
670
|
+
@errors += 1
|
671
|
+
raise TftpError, "ERR packet: #{pkt.errmsg}"
|
672
|
+
|
673
|
+
elsif pkt.is_a? TftpPacketOACK
|
674
|
+
unless @state == :rrq
|
675
|
+
@errors += 1
|
676
|
+
$stderr.write "It is a OACK in state #{@state}"
|
677
|
+
next
|
678
|
+
end
|
679
|
+
|
680
|
+
@state = :oack
|
681
|
+
# Are the acknowledged options the same as ours?
|
682
|
+
# FIXME - factor this into the OACK class?
|
683
|
+
if pkt.options
|
684
|
+
pkt.options do |optname, optval|
|
685
|
+
case optname
|
686
|
+
when :blksize
|
687
|
+
# The blocksize can be <= what we proposed.
|
688
|
+
unless options.has_key? :blksize
|
689
|
+
# Hey, we didn't ask for a blocksize option...
|
690
|
+
err = TftpPacketERR.new
|
691
|
+
err.errorcode = 8 # failed negotiation
|
692
|
+
sock.send(err.encode.buffer, 0, @host, @port)
|
693
|
+
raise TftpError, "It is a OACK with blocksize when we didn't ask for one."
|
694
|
+
end
|
695
|
+
|
696
|
+
if optval <= options[:blksize] and optval >= MinBlkSize
|
697
|
+
# Valid. Lets use it.
|
698
|
+
options[:blksize] = optval
|
699
|
+
end
|
700
|
+
else
|
701
|
+
# FIXME - refactor err packet handling from above...
|
702
|
+
# Nothing that we don't know of should be in the
|
703
|
+
# oack packet.
|
704
|
+
err = TftpPacketERR.new
|
705
|
+
err.errorcode = 8 # failed negotiation
|
706
|
+
sock.send(err.encode.buffer, 0, @host, @port)
|
707
|
+
raise TftpError, "Failed to negotiate options: #{pkt.options}"
|
708
|
+
end
|
709
|
+
end
|
710
|
+
# SUCCESSFUL NEGOTIATION
|
711
|
+
# If we're here, then we're happy with the options in the
|
712
|
+
# OACK. Send an ACK of block 0 to ACK the OACK.
|
713
|
+
# FIXME - further negotiation required here?
|
714
|
+
ack = TftpPacketACK.new
|
715
|
+
ack.blocknumber = 0
|
716
|
+
sock.send(ack.encode.buffer, 0, @host, @port)
|
717
|
+
@state = :ack
|
718
|
+
else
|
719
|
+
# OACK with no options?
|
720
|
+
err = TftpPacketERR.new
|
721
|
+
err.errorcode = 8 # failed negotiation
|
722
|
+
sock.send(err.encode.buffer, 0, @host, @port)
|
723
|
+
raise TftpError, "OACK with no options"
|
724
|
+
end
|
725
|
+
|
726
|
+
# Done parsing. If we didn't raise an exception, then we need
|
727
|
+
# to send an ACK to the server, with block number 0.
|
728
|
+
ack = TftpPacketACK.new
|
729
|
+
ack.blocknumber = 0
|
730
|
+
$tftplog.info('tftp+') { "Sending ACK to OACK" }
|
731
|
+
sock.send(ack.encode.buffer, 0, @host, @port)
|
732
|
+
@state = :ack
|
733
|
+
|
734
|
+
elsif pkt.is_a? TftpPacketDAT
|
735
|
+
# If the state is :rrq, and we sent options, then the
|
736
|
+
# server didn't send us an oack, and the options were refused.
|
737
|
+
# FIXME - we need to handle all possible options and set them
|
738
|
+
# back to their defaults here, not just blocksize.
|
739
|
+
if @state == :rrq and options.has_key? :blksize
|
740
|
+
@blksize = DefBlkSize
|
741
|
+
end
|
742
|
+
|
743
|
+
@state = :dat
|
744
|
+
$tftplog.info('tftp+') { "It is a DAT packet, block #{pkt.blocknumber}" }
|
745
|
+
$tftplog.debug('tftp+') { "DAT size is #{pkt.data.length}" }
|
746
|
+
|
747
|
+
ack = TftpPacketACK.new
|
748
|
+
ack.blocknumber = pkt.blocknumber
|
749
|
+
|
750
|
+
$tftplog.info('tftp+') { "Sending ACK to block #{ack.blocknumber}" }
|
751
|
+
sock.send(ack.encode.buffer, 0, @host, @port)
|
752
|
+
|
753
|
+
# Check for dups
|
754
|
+
if pkt.blocknumber <= blocknumber
|
755
|
+
$tftplog.warn('tftp+') { "It is a DUP for block #{blocknumber}" }
|
756
|
+
@dups += 1
|
757
|
+
elsif pkt.blocknumber = blocknumber+1
|
758
|
+
$tftplog.debug('tftp+') { "It is a properly ordered DAT packet" }
|
759
|
+
blocknumber += 1
|
760
|
+
else
|
761
|
+
# Skip it, but info('tftp+')rm the sender.
|
762
|
+
err = TftpPacketERR.new
|
763
|
+
err.errorcode = 4 # illegal op
|
764
|
+
sock.send(err.encode.buffer, 0, @host, @port)
|
765
|
+
@errors += 1
|
766
|
+
$stderr.write "It is a future packet!\n"
|
767
|
+
end
|
768
|
+
|
769
|
+
# Call any block passed.
|
770
|
+
if block_given?
|
771
|
+
yield pkt
|
772
|
+
end
|
773
|
+
|
774
|
+
# Write the data to the file.
|
775
|
+
fout.print pkt.data
|
776
|
+
# If the size is less than our blocksize, we're done.
|
777
|
+
$tftplog.debug('tftp+') { "pkt.data.length is #{pkt.data.length}" }
|
778
|
+
if pkt.data.length < @blksize
|
779
|
+
$tftplog.info('tftp+') { "It is a last packet." }
|
780
|
+
fout.close
|
781
|
+
@state = :done
|
782
|
+
break
|
783
|
+
end
|
784
|
+
else
|
785
|
+
msg = "It is an unknown packet: #{pkt}"
|
786
|
+
$tftplog.error('tftp+') { msg }
|
787
|
+
raise TftpError, msg
|
788
|
+
end
|
789
|
+
end
|
790
|
+
end
|
791
|
+
end
|
792
|
+
|
793
|
+
# If invoked directly...
|
794
|
+
if __FILE__ == $0
|
795
|
+
# Simple client maybe?
|
796
|
+
end
|
data/test/test.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$:.unshift File.join(File.dirname(__FILE__), "..", "lib")
|
4
|
+
require 'net/tftp+'
|
5
|
+
require 'test/unit'
|
6
|
+
|
7
|
+
class TestTftp < Test::Unit::TestCase
|
8
|
+
def test_simple
|
9
|
+
rrq = TftpPacketRRQ.new
|
10
|
+
rrq.filename = 'myfilename'
|
11
|
+
rrq.mode = 'octet'
|
12
|
+
rrq.encode
|
13
|
+
assert_equal("\000\001myfilename\000octet\000", rrq.buffer)
|
14
|
+
assert_equal(1, rrq.opcode)
|
15
|
+
rrq.decode
|
16
|
+
assert_equal('myfilename', rrq.filename)
|
17
|
+
assert_equal('octet', rrq.mode)
|
18
|
+
|
19
|
+
wrq = TftpPacketWRQ.new
|
20
|
+
wrq.buffer = "\000\002myfilename\000octet\000"
|
21
|
+
wrq.decode
|
22
|
+
assert_equal('myfilename', wrq.filename)
|
23
|
+
assert_equal('octet', wrq.mode)
|
24
|
+
assert_equal(2, wrq.opcode)
|
25
|
+
wrq.encode.decode
|
26
|
+
assert_equal('myfilename', wrq.filename)
|
27
|
+
assert_equal('octet', wrq.mode)
|
28
|
+
assert_equal(2, wrq.opcode)
|
29
|
+
|
30
|
+
dat = TftpPacketDAT.new
|
31
|
+
sampledat = "\000\001\002\003\004\005"
|
32
|
+
dat.data = sampledat
|
33
|
+
dat.encode.decode
|
34
|
+
assert_equal(sampledat, dat.data)
|
35
|
+
assert_equal(6, dat.data.length)
|
36
|
+
assert_equal(3, dat.opcode)
|
37
|
+
|
38
|
+
ack = TftpPacketACK.new
|
39
|
+
ack.blocknumber = 5
|
40
|
+
assert_equal(4, ack.opcode)
|
41
|
+
assert_equal(5, ack.encode.decode.blocknumber)
|
42
|
+
|
43
|
+
err = TftpPacketERR.new
|
44
|
+
err.errorcode = 3
|
45
|
+
assert_equal('Disk full or allocation exceeded.',
|
46
|
+
err.encode.decode.errmsg)
|
47
|
+
assert_equal(5, err.opcode)
|
48
|
+
|
49
|
+
oack = TftpPacketOACK.new
|
50
|
+
oack_options = {
|
51
|
+
:blksize => 4096
|
52
|
+
}
|
53
|
+
oack.options = oack_options
|
54
|
+
oack.encode.decode
|
55
|
+
assert_equal('4096', oack.options[:blksize])
|
56
|
+
assert_equal(6, oack.opcode)
|
57
|
+
end
|
58
|
+
end
|
metadata
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
rubygems_version: 0.9.0
|
3
|
+
specification_version: 1
|
4
|
+
name: tftpplus
|
5
|
+
version: !ruby/object:Gem::Version
|
6
|
+
version: "0.2"
|
7
|
+
date: 2006-12-08 00:00:00 -05:00
|
8
|
+
summary: A pure tftp implementation with support for variable block sizes
|
9
|
+
require_paths:
|
10
|
+
- lib
|
11
|
+
email: msoulier@digitaltorque.ca
|
12
|
+
homepage: http://tftpplus.rubyforge.org
|
13
|
+
rubyforge_project:
|
14
|
+
description: A new tftp library for clients and servers that supports RFCs 1350, 2347 and 2348 (ie. variable block sizes). It includes a sample client implementation, and will eventually include a multi-threaded server as well.
|
15
|
+
autorequire:
|
16
|
+
default_executable:
|
17
|
+
bindir: bin
|
18
|
+
has_rdoc: false
|
19
|
+
required_ruby_version: !ruby/object:Gem::Version::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">"
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 0.0.0
|
24
|
+
version:
|
25
|
+
platform: ruby
|
26
|
+
signing_key:
|
27
|
+
cert_chain:
|
28
|
+
post_install_message:
|
29
|
+
authors:
|
30
|
+
- Michael P. Soulier
|
31
|
+
files:
|
32
|
+
- lib/net/tftp+.rb
|
33
|
+
- README
|
34
|
+
- ChangeLog
|
35
|
+
- test/test.rb
|
36
|
+
test_files:
|
37
|
+
- test/test.rb
|
38
|
+
rdoc_options: []
|
39
|
+
|
40
|
+
extra_rdoc_files: []
|
41
|
+
|
42
|
+
executables:
|
43
|
+
- tftp_client.rb
|
44
|
+
extensions: []
|
45
|
+
|
46
|
+
requirements: []
|
47
|
+
|
48
|
+
dependencies: []
|
49
|
+
|