scriptroute 0.4.14

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.
@@ -0,0 +1,449 @@
1
+
2
+ # Ally is a class;
3
+ # the class object caches the results of previous tests.
4
+ # an object of the class is the name / object space in which
5
+ # a test is run.
6
+
7
+ $have_undns = begin
8
+ require 'undns'
9
+ true
10
+ rescue LoadError, SecurityError
11
+ false
12
+ end
13
+ $have_socket = begin
14
+ require 'socket'
15
+ true
16
+ rescue LoadError, SecurityError
17
+ false
18
+ end
19
+
20
+ module Scriptroute
21
+ class Ally
22
+
23
+ # the cache of previously seen results is a class variable.
24
+ @@result_cache = Hash.new;
25
+
26
+ # use bdb41 to save the cache of results persistently, in case we
27
+ # would like to restore it. This sets the cache to be on disk
28
+ # (so we can read from it, and it will exist after the script)
29
+ # instead of in memory (created and destroyed with every invocation).
30
+ # @param [String] dbfilename where to store the cache.
31
+ # @return [void]
32
+ # @note the cache does not expire entries. The cache should probably not
33
+ # be used after, say, a month, since topologies can change.
34
+ # @note if bdb41 cannot be loaded, the cache will not be made persistent, and
35
+ # an error will print to stderr.
36
+ def Ally.make_result_cache_persistent(dbfilename)
37
+ begin
38
+ require "bdb41"
39
+ @@result_cache = BDB::Hash.new(dbfilename, nil, BDB::CREATE)
40
+ rescue LoadError
41
+ $stderr.puts "Unable to make result cache persistent: install bdb41 (libdb4.1-ruby)"
42
+ end
43
+ end
44
+ # Iterate through the result cache, yielding each pair of IP addresses
45
+ # found to be aliases.
46
+ # @yield [String,String]
47
+ def Ally.each_alias
48
+ @@result_cache.each { |k,v|
49
+ if v =~ /^ALIAS/ then
50
+ yield k.split(":")
51
+ end
52
+ }
53
+ end
54
+
55
+ # keys in the cache are concatenations of IP addresses.
56
+ # since alias relations are symmetric (reflexive?), the
57
+ # addresses are sorted first.
58
+ # @return [String] a key for use in the cache hashtable.
59
+ def Ally.to_key(ip_a, ip_b)
60
+ [ ip_a, ip_b ].sort.join(':')
61
+ end
62
+
63
+ # a quick shorthand for finding where our object belongs
64
+ # in the cache.
65
+ # @return [String] a key for use in the cache hashtable.
66
+ def my_key
67
+ Ally.to_key(@a.ip_dst, @b.ip_dst)
68
+ end
69
+
70
+ # when we've decided that something is an alias, not an alias
71
+ # or unknown, we call these functions. they may be overridden
72
+ # by a derived class to create different (perhaps hooked) behavior.
73
+ # however, super() should be called first to cache the result.
74
+
75
+ # @param msg [String] why we cannot figure out this pair
76
+ # @return [void]
77
+ def unknown(msg)
78
+ @verdict = "UNKNOWN. #{msg}"
79
+ @@result_cache[my_key] = @verdict;
80
+ throw :resolved # much like a return in the calling scope.
81
+ end
82
+ # @param msg [String] why we believe this pair to be aliases
83
+ # @return [void]
84
+ def is_alias(msg)
85
+ @verdict = "ALIAS! #{msg}"
86
+ @@result_cache[my_key] = @verdict;
87
+ throw :resolved
88
+ end
89
+ # @param msg [String] why we believe this pair to be not aliases
90
+ # @return [void]
91
+ def not_alias(msg)
92
+ @verdict = "NOT ALIAS. #{msg}"
93
+ @@result_cache[my_key] = @verdict;
94
+ throw :resolved
95
+ end
96
+
97
+ # a helper function to handle comparing ipid's, as they are
98
+ # unsigned short counters that can wrap.
99
+ # @return [Boolean]
100
+ def Ally.before(seq1,seq2)
101
+ diff = seq1-seq2
102
+ # emulate signed short arithmetic.
103
+ if (diff > 32767) then
104
+ diff-=65535;
105
+ elsif (diff < -32768) then
106
+ diff+=65535;
107
+ end
108
+ # puts "#{seq1} #{diff < 0 ? "" : "not"} before #{seq2}\n"
109
+ (diff < 0)
110
+ end
111
+ # a helper function to handle comparing ipid's, as they are
112
+ # unsigned short counters that can wrap.
113
+ # @return [Boolean]
114
+ def before
115
+ Ally.before(seq1,seq2)
116
+ end
117
+
118
+ # do a reverse lookup on an IP address. Be prepared to handle an exception,
119
+ # as in safe mode, this is not permitted.
120
+ # @param [String] ip the IP address to convert.
121
+ # @return [String] the output of Socket.gethostbyname
122
+ # @raise [RuntimeError] if gethostbyname throws an exception.
123
+ def ip_to_name(ip)
124
+ begin
125
+ (Socket.gethostbyname(ip)[0]).gsub(/\"/,'')
126
+ rescue => e
127
+ # provide a more informative exception.
128
+ raise "#{ip} #{e}"
129
+ end
130
+ end
131
+
132
+ # if we can't tell using packets, try using undns to guess
133
+ # using the names attached to these interfaces.
134
+ def try_undns(msg)
135
+ # we can't tell using packets whether the two are aliases.
136
+
137
+ if(!$have_undns) then
138
+ # throws out (return implied)
139
+ unknown "#{msg}; undns not loaded."
140
+ end
141
+
142
+ # try a reverse lookup, then match the names using established
143
+ # rules. This requires access to the file system so won't
144
+ # work if run remotely. (it will jump to the rescue line and
145
+ # print unknown).
146
+ begin
147
+ # the next fragment is designed to try both lookups first,
148
+ # then try both through undns second. this means we won't
149
+ # complain about undns unless we would have had a chance of
150
+ # using it.
151
+ begin
152
+ a_name, b_name = [ @a.ip_dst, @b.ip_dst ].map { |dst|
153
+ ip_to_name(dst)
154
+ }
155
+ begin
156
+ a_uniq, b_uniq = [ a_name, b_name ].map { |name|
157
+ uniq = Undns.get_identifier(0, name)
158
+ if( uniq == nil || uniq == '') then
159
+ raise "#{name} lacks unique fragments"
160
+ end
161
+ uniq
162
+ }
163
+ if(a_uniq == b_uniq) then
164
+ is_alias "name: #{a_uniq}, otherwise #{msg}"
165
+ else
166
+ not_alias "name: #{a_uniq} != #{b_uniq} and #{msg}"
167
+ end
168
+ rescue => e
169
+ # rule them out if the names say different cities, even
170
+ # if we can't tell the specific pattern to prove that
171
+ # addresses are aliases. this is just an optimization --
172
+ # unknown is usually treated as "no" anyway.
173
+ a_city, b_city = [ a_name, b_name ].map { |name|
174
+ city = Undns.get_loc(0, name)
175
+ if( city == nil || city == '') then
176
+ raise "#{name} lacks unique fragments and city location"
177
+ end
178
+ city
179
+ }
180
+ if(a_city == b_city) then
181
+ unknown "#{msg}; undns failed and cities are the same: #{e}"
182
+ else
183
+ not_alias "name: cities #{a_city} != #{b_city} and #{msg}"
184
+ end
185
+ end
186
+ end
187
+ rescue SecurityError => e
188
+ unknown "#{msg}; undns failed (securityerror): #{e}"
189
+ rescue LoadError => e
190
+ unknown "#{msg}; loaderror undns failed: #{e}"
191
+ rescue => e
192
+ # unable to lookup, don't have undns, etc.
193
+ unknown "#{msg}; undns failed: #{e}"
194
+ end
195
+ end
196
+
197
+ # responses we can get include address unreachable, which means we're
198
+ # not talking to the intended router. Check first.
199
+ def assert_response_non_bogus(resp)
200
+ if( ! resp.response ) then
201
+ raise "(bug!) assert_response_non_bogus shouldn't check that a response was received"
202
+ end
203
+ if(@a.is_a?(Scriptroute::UDP)) then
204
+ # if we tried to probe using udp traceroute-like, we're expecting a port unreach.
205
+ # if we don't get it, likely filtered, try undns
206
+ if((! resp.response.packet.is_a?(Scriptroute::ICMP)) or
207
+ ( resp.response.packet.icmp_type != Scriptroute::ICMP::ICMP_UNREACH ) or
208
+ ( resp.response.packet.icmp_code != Scriptroute::ICMP::ICMP_UNREACH_PORT )) then
209
+ try_undns "filtered: #{resp.probe.packet.ip_dst}"
210
+ end
211
+ elsif(@a.is_a?(Scriptroute::TCP)) then
212
+ # if we tried to probe using tcp 80, we're expecting a tcp rst.
213
+ # if we don't get it, likely filtered, try undns
214
+ if(! resp.response.packet.is_a?(Scriptroute::TCP)) then
215
+ try_undns "filtered: #{resp.probe.packet.ip_dst}"
216
+ end
217
+ elsif(@a.is_a?(Scriptroute::ICMP) &&
218
+ @a.icmp_type == Scriptroute::ICMP_ECHO ) then
219
+ # if we tried to probe using tcp 80, we're expecting a tcp rst.
220
+ # if we don't get it, likely filtered, try undns
221
+ if((! resp.response.packet.is_a?(Scriptroute::ICMP)) or
222
+ resp.response.packet.icmp_type != Scriptroute::ICMP_ECHOREPLY) then
223
+ try_undns "filtered: #{resp.probe.packet.ip_dst}"
224
+ end
225
+ else
226
+ # we don't seem to know what to do.
227
+ fail "Can't recognize expected response to #{@a.to_s}."
228
+ end
229
+ end
230
+
231
+ private
232
+ def merc(exchange)
233
+ exchange.probe.packet.ip_dst + '->' + exchange.response.packet.ip_src
234
+ end
235
+
236
+ # @param destination [String, IPaddress] a destination for assignment using {Scriptroute::IPv4.ip_dst=}
237
+ # @param type [String] "udp", "tcp", or "ping" to determine what to send.
238
+ # @return [IPv4] a packet of specified type
239
+ def packet_creator(destination, type)
240
+ case type
241
+ when 'udp'
242
+ probe = Scriptroute::UDP.new(12)
243
+ when 'tcp'
244
+ probe = Scriptroute::TCP.new(0)
245
+ when 'ping'
246
+ @seq = @seq ? @seq + 1 : 0
247
+ probe = Scriptroute::ICMPecho.new(0)
248
+ # probe.icmp_type = Scriptroute::Icmp::ICMP_ECHO
249
+ # probe.icmp_code = 0
250
+ probe.icmp_seq = @seq
251
+ else
252
+ raise "unknown probe type #{type}"
253
+ end
254
+ probe.ip_dst = destination
255
+ probe
256
+ end
257
+
258
+ # @param [Array] id_array an array of IP IDs to check whether unique (duplicates suggest non-aliases.)
259
+ # @return [Boolean] whether the id_array is unique
260
+ def unique_ids(id_array)
261
+ id_array.uniq.length == id_array.length
262
+ end
263
+
264
+ # Handles the case where one or both of the candidate
265
+ # addresses are unresponsive, thus, Ally won't work.
266
+ def unresponsive(*all)
267
+ if all.length > 1 then
268
+ try_undns "two unresponsive: #{all.join(' and ')}"
269
+ else
270
+ try_undns "one unresponsive: #{all.join(' and ')}"
271
+ end
272
+ end
273
+
274
+ public
275
+
276
+ # create an object that will represent our attempt to test
277
+ # two addresses. the parameters may be hostnames instead of
278
+ # addresses.
279
+ # @param ip_a [String, IPaddress] The first address to test if an alias for the second.
280
+ # @param ip_b [String, IPaddress] The second address to test if an alias for the first.
281
+ # @param type [String] "udp", "tcp" (not useful?), or "icmp"
282
+ def initialize(ip_a, ip_b, type='udp')
283
+ @a = packet_creator(ip_a, type)
284
+ @b = packet_creator(ip_b, type)
285
+
286
+ # for now, we don't know.
287
+ @verdict = "UNKNOWN: Failed to complete"
288
+
289
+ # we'll throw :resolved when we've figured it out; this is
290
+ # to simplify the if/elsif/elsif insanity
291
+ catch :resolved do
292
+
293
+ # the quick test; handling this test through the packet
294
+ # test causes confusion. It is after packet construction
295
+ # so that the names are looked up to addresses
296
+ if( @a.ip_dst == @b.ip_dst ) then
297
+ is_alias "trivial, #{@a.ip_dst} = #{@b.ip_dst}"
298
+ end
299
+
300
+ ## this is entirely too complicated, and needs a rewrite.
301
+ packets = Scriptroute::send_train([ Struct::DelayedPacket.new(0,@a),
302
+ Struct::DelayedPacket.new(0.001,@b) ])
303
+
304
+ ## try again if we had a pcap overload style problem
305
+ if(packets.length < 2 ||
306
+ packets[0] == nil || packets[0].probe == nil ||
307
+ packets[1] == nil || packets[1].probe == nil) then
308
+ packets = Scriptroute::send_train([ Struct::DelayedPacket.new(0,@a),
309
+ Struct::DelayedPacket.new(0.001,@b) ])
310
+ if(packets.length < 2 ||
311
+ packets[0] == nil || packets[0].probe == nil ||
312
+ packets[1] == nil || packets[1].probe == nil) then
313
+ try_undns "Internal error: #{@a.ip_dst} and #{@b.ip_dst}"
314
+ end
315
+ end
316
+
317
+ if(packets[0].response && packets[1].response) then
318
+
319
+ assert_response_non_bogus(packets[0])
320
+ assert_response_non_bogus(packets[1])
321
+
322
+ id0 = packets[0].response.packet.ip_id
323
+ id1 = packets[1].response.packet.ip_id
324
+
325
+ if(packets[0].response.packet.ip_src ==
326
+ packets[1].response.packet.ip_src) then
327
+ is_alias "mercator/source address: #{merc(packets[0])} #{merc(packets[1])}"
328
+
329
+ elsif( id0 == id1 ) then
330
+ # when they're the same, it's either:
331
+ if ( id0 == 0 ) then
332
+ # a) a lack of implementation
333
+ try_undns "IPIDs not used: both are zero"
334
+ else
335
+ # b) not aliases.
336
+ not_alias "Same IPID."
337
+ end
338
+
339
+ elsif(before(id0-10, id1) && before(id1, id0+200)) then
340
+ # adding a delay here (the 0.40) seems to increase the likelihood
341
+ # of a response.
342
+ packetz = Scriptroute::send_train([ Struct::DelayedPacket.new(0.40,@b),
343
+ Struct::DelayedPacket.new(0.001,@a) ])
344
+ if(packetz[0] == nil || packetz[1] == nil) then
345
+ packetz = Scriptroute::send_train([ Struct::DelayedPacket.new(0.40,@b),
346
+ Struct::DelayedPacket.new(0.001,@a) ])
347
+ if(packetz[0] == nil || packetz[1] == nil) then
348
+ raise "couldn't send the second set of packets"
349
+ end
350
+ end
351
+ if(packetz[0].response && packetz[1].response) then
352
+ id2 = packetz[0].response.packet.ip_id
353
+ id3 = packetz[1].response.packet.ip_id
354
+ assert_response_non_bogus(packetz[0])
355
+ assert_response_non_bogus(packetz[1])
356
+ if(before(id2-10, id3) &&
357
+ before(id3, id2+200) &&
358
+ before(id0, id2) &&
359
+ before(id1, id3) &&
360
+ unique_ids( [ id0, id1, id2, id3 ] ) ) then
361
+ is_alias "ally/ipid: #{[id0, id1, id2, id3].join(', ')}"
362
+ else
363
+ not_alias "disparate ids: #{[id0, id1, id2, id3].join(', ')}"
364
+ end
365
+ elsif(packetz[0].response || packetz[1].response) then
366
+ last = packetz[ (packetz[0].response) ? 0 : 1 ]
367
+
368
+ assert_response_non_bogus(last)
369
+ id2 = last.response.packet.ip_id
370
+ if(before(id0, id2) &&
371
+ before(id1, id2) &&
372
+ unique_ids( [ id0, id1, id2 ] )) then
373
+ is_alias "ally/ipid; less response: #{[id0, id1, id2].join(', ')}"
374
+ else
375
+ not_alias "disparate ids (3): #{[id0, id1, id2].join(', ')}"
376
+ end
377
+ else
378
+ is_alias "ally/ipid; presumptive (second round had no responses): #{[id0, id1].join(', ')}"
379
+ end
380
+ else
381
+ not_alias "quick (2): #{[id0, id1].join(', ')}"
382
+ ## #{[id0, id1].map {|v| (((v&0xff)*256) + v/256)}.join(', ')} "
383
+ end
384
+ elsif(packets[0].response || packets[1].response) then
385
+ first = packets[ (packets[0].response) ? 0 : 1 ]
386
+
387
+ # we received only one response.
388
+ # try sending again, reordered.
389
+ packetz = Scriptroute::send_train([ Struct::DelayedPacket.new(0,@b),
390
+ Struct::DelayedPacket.new(0.001,@a) ])
391
+ if(packetz[0].response || packetz[1].response) then
392
+ # we received at least one response
393
+ second = packetz[ (packetz[0].response) ? 0 : 1 ]
394
+ assert_response_non_bogus(second)
395
+ if((second.probe.packet.ip_dst != second.response.packet.ip_src ||
396
+ first.probe.packet.ip_dst != first.response.packet.ip_src) &&
397
+ first.response.packet.ip_src == second.response.packet.ip_src) then
398
+ # shows the signature of a cisco ( responds with a different source address )
399
+ if(second.probe.packet.ip_dst != first.probe.packet.ip_dst) then
400
+ # and responses to two different requests
401
+ # puts second.response.packet
402
+ # puts first.response.packet
403
+ is_alias "mercator/source address rate limited: #{merc(first)} #{merc(second)}"
404
+ else
405
+ # the destination of both probes we got answers to was the same.
406
+ # the other destination was unresponsive.
407
+ unresponsive(packets[packets[0].response ? 1 : 0].probe.packet.ip_dst)
408
+ end
409
+ else
410
+ # not necessarily true? might have just lost the first packet in the
411
+ # first round.
412
+ unresponsive(packets[packets[0].response ? 1 : 0].probe.packet.ip_dst)
413
+ end
414
+ else
415
+ unresponsive(packets[packets[0].response ? 1 : 0].probe.packet.ip_dst)
416
+ end
417
+ else
418
+ unresponsive(@a.ip_dst, @b.ip_dst)
419
+ end
420
+ fail "shouldn't get here under any circumstances."
421
+ end # catch.
422
+ end
423
+
424
+ # @return [String] the "verdict"
425
+ def to_s
426
+ @verdict
427
+ end
428
+
429
+ # @return [Boolean] whether the "verdict" is "ALIAS", ignoring the explanation.
430
+ def is?
431
+ # redundancy is for testing. want to be able to compare
432
+ # true == true.
433
+ true & (@verdict =~ /^ALIAS/)
434
+ end
435
+
436
+ # Checks the cache, and if no entry is present, creates a
437
+ # new alias resolution test object to probe.
438
+ # @return [Boolean] whether the "verdict" is "ALIAS",
439
+ # ignoring the explanation.
440
+ def Ally.aliases?(ip_a, ip_b)
441
+ key = Ally.to_key(ip_a, ip_b)
442
+ if(!@@result_cache.has_key?(key)) then
443
+ Ally.new(ip_a, ip_b)
444
+ end
445
+ @@result_cache[key] =~ /^ALIAS/
446
+ end
447
+ end
448
+
449
+ end
@@ -0,0 +1,228 @@
1
+ # base class of command options. all options must have tags
2
+ # and a helpful description... or at least a description.
3
+ class CommandoOpt
4
+ attr_reader :tags, :description
5
+ protected
6
+ def initialize(tags, description)
7
+ raise ArgumentError, "description must be a string (not #{description.class})" unless(description.is_a?(String))
8
+ if(!tags.is_a?(Array)) then
9
+ tags = [tags]
10
+ end
11
+ @tags = tags
12
+ @description = description
13
+ end
14
+ public
15
+ # @return [String] the help text for this option,
16
+ # comprising tags, default, and description
17
+ def help
18
+ " %20s %4s %-72s" % [ tags.join(", "),
19
+ self.string_default,
20
+ @description ]
21
+ end
22
+ # @note Modifies argument (removing parsed options)
23
+ # @param [Array<String>] argv the arguments to parse.
24
+ def seek(argv)
25
+ argv.each_with_index { |a,i|
26
+ tags.each { |t|
27
+ if(a == t) then
28
+ argv.delete_at(i)
29
+ if(takes_argument?) then
30
+ set(argv[i])
31
+ argv.delete_at(i)
32
+ else
33
+ set(TRUE) # if closure, then the arg is ignored.
34
+ end
35
+ end
36
+ }
37
+ }
38
+ end
39
+ end
40
+
41
+ # simple class for setting variables in command processing.
42
+ class CommandoVar < CommandoOpt
43
+ # @return [Symbol] the name of the variable to set
44
+ attr_reader :varname
45
+ # @return [Object] the default value of the variable
46
+ attr_reader :default
47
+
48
+ # @return [Boolean] whether the option takes a parameter,
49
+ # based on whether the default is true or false.
50
+ def takes_argument?
51
+ # not really right, it should check the arity of the
52
+ # closure if present.
53
+ !(@default == true || @default == false)
54
+ end
55
+ # @return [String] the default argument, if any, or an
56
+ # empty string if no arguments are taken.
57
+ def string_default
58
+ if takes_argument? then
59
+ @default.to_s
60
+ else
61
+ ""
62
+ end
63
+ end
64
+
65
+ # basic assignment, if it's a string, we quote it, else
66
+ # the interpreter should deal with numbers, and complain
67
+ # if something should have been quoted. This operation
68
+ # allows crazy s**t to happen, so don't use this in setuid code.
69
+ #
70
+ # @param argument [String] the value that the variable
71
+ # should take, either from the default or from the
72
+ # command line.
73
+ # @return [Fixnum,String] likely the argument as
74
+ # interpreted by eval. Not likely to be useful.
75
+ def set(argument)
76
+ Kernel.eval( if(@default.is_a?(String)) then
77
+ "#{@varname} = \"#{argument}\""
78
+ else
79
+ "#{@varname} = #{argument}"
80
+ end
81
+ )
82
+ end
83
+
84
+ # @param tags [Array<String>,String] the options to
85
+ # recognize, either just a string "--option" or an array [
86
+ # "-o", "--option" ]
87
+ # @param description [String] help text to describe this option.
88
+ # @param varname [Symbol] a global symbol to set to the
89
+ # value, e.g., ":$Option"
90
+ # @param default [Object] a default value for the option,
91
+ # if not set, useful for the help description. If the default
92
+ # is not a string, the assignment evals the variable unquoted.
93
+ # (That is, integer arguments should work as numbers without
94
+ # a need to convert.)
95
+ def initialize(tags, description, varname, default=nil)
96
+ # should probably ensure that this isn't running setuid.
97
+ super(tags, description)
98
+ @varname = varname
99
+ @default = default
100
+ set(@default)
101
+ end
102
+ end
103
+
104
+ # instead of setting a variable, execute an anonymous function.
105
+ # if the function takes an arg, gobble the next command line
106
+ # argument. If an argument is taken, the function is responsible
107
+ # for converting the string to whatever type is desired, and
108
+ # validating the input -- raising an exception if a failure occurs.
109
+ class CommandoClosure < CommandoOpt
110
+ attr_reader :closure
111
+ # @return [Boolean] whether the option takes a parameter
112
+ def takes_argument?
113
+ (@closure.arity == -2) # means it takes one arg.
114
+ end
115
+
116
+ # @return [String] the default value, mostly for
117
+ # compatibility with CommandoVar.
118
+ def string_default
119
+ if takes_argument? then
120
+ "[x]" # return something to appease the help msg.
121
+ else
122
+ ""
123
+ end
124
+ end
125
+
126
+ # basic assignment, if it's a string, we quote it, else
127
+ # the interpreter should deal with numbers, and complain
128
+ # if something should have been quoted. This operation
129
+ # allows crazy s**t to happen, so don't use this in setuid code.
130
+ #
131
+ # @param argument [String] the value that the variable
132
+ # should take, either from the default or from the
133
+ # command line.
134
+ def set(argument)
135
+ case @closure.arity
136
+ when -1
137
+ @closure.call
138
+ when -2
139
+ @closure.call argv[i]
140
+ argv.delete_at(i)
141
+ else
142
+ raise "strange closure takes #{@closure.arity} args"
143
+ end
144
+ end
145
+
146
+ # @param tags [Array<String>,String] the options to
147
+ # recognize, either just a string "--option" or an array [
148
+ # "-o", "--option" ]
149
+ # @param description [String] help text to describe this option.
150
+ # @param closure [Proc] the method to invoke if this option is
151
+ # seen; the number of parameters to the Proc determines how
152
+ # many subsequent arguments are considered parameters for this
153
+ # option.
154
+ def initialize(tags, description, closure)
155
+ raise ArgumentError, "CommandoClosure takes a lambda as the third arg" unless( closure.is_a?(Proc) )
156
+ # ruby1.8 seemed to use -1/-2 here; ruby1.9 seems to use
157
+ # 0/1 here. accept em all.
158
+ raise ArgumentError, "Closure for #{tags} should take zero or one args, not #{closure.arity}" unless(closure.arity == -1 or closure.arity == -2 or closure.arity == 0 or closure.arity == 1)
159
+
160
+ super(tags, description)
161
+ @closure = closure
162
+ end
163
+ end
164
+
165
+ # @example
166
+ # require "scriptroute/commando"
167
+ #
168
+ # c = Commando.new(ARGV, # allows substitution by srclient.rb
169
+ # [ CommandoVar.new( "--start-speed",
170
+ # "Kbps rate to start" ,
171
+ # :$StartSpeedKbps, 20),
172
+ # CommandoVar.new( "--train-length",
173
+ # "Filler packets in train" ,
174
+ # :$TrainLength, 20 ),
175
+ # CommandoVar.new( "--hop",
176
+ # "Specific hop to study (ttl=hop and hop-1), 0 for e2e" ,
177
+ # :$Hop, 0 ),
178
+ # CommandoVar.new( [ "--verbose", "-v" ],
179
+ # "print gobs of messages",
180
+ # :$VERBOSE, false ) ],
181
+ # "destination-host")
182
+ #
183
+ # raise "must start with a positive rate" if($StartSpeedKbps <= 0)
184
+ # if(ARGV[0] == nil) then
185
+ # c.usage
186
+ # exit
187
+ # end
188
+ #
189
+ # Main class for option parsing.
190
+ #
191
+ # Limitations:
192
+ # doesn't currently grok the single letter getopt-style
193
+ # arguments, so "foo.rb -v -d -u" cannot be expressed as
194
+ # "foo.rb -vdu".
195
+ #
196
+ class Commando
197
+ # Print a usage message to stdout.
198
+ # @return [void]
199
+ def usage()
200
+ puts "Usage: #{$0} [options] #{@after_options_name}"
201
+ puts " %18s %4s %-72s" % [ "option", "default", "description" ]
202
+ puts @options.map { |o|
203
+ o.help
204
+ }.join("\n")
205
+ end
206
+ # Parse any recognized command line options, removing
207
+ # anything parsed from ARGV.
208
+ # @param argv [Array<String>] ARGV as provided by the interpreter.
209
+ # @param options [Array [CommandoVar] an array of option descriptions
210
+ # @param after_options_name [String] the description of
211
+ # what comes after the options parsed.
212
+ def initialize(argv, options, after_options_name)
213
+ @options = options
214
+ @after_options_name = after_options_name
215
+ helpopt = CommandoClosure.new(["--help", "-h"], "this help",
216
+ lambda { |a| self.usage; exit 0 })
217
+ begin
218
+ # helpopt goes last, in case the programmer wrote
219
+ # a -h option in.
220
+ (options + [helpopt]).each { |o| o.seek(argv) }
221
+ rescue NameError => e
222
+ puts "Error in command line parsing: #{e}"
223
+ puts
224
+ self.usage
225
+ exit
226
+ end
227
+ end
228
+ end