scriptroute 0.4.14

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