tftpplus 0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|