iqeo-hostspec 0.1.0.pre1

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/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Iqeo::Hostspec
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'iqeo-hostspec'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install iqeo-hostspec
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/bin/hostspec ADDED
@@ -0,0 +1,131 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ require 'iqeo/hostspec'
4
+
5
+ class Runner
6
+
7
+ def self.run args, out: $stdout, err: $stderr
8
+
9
+ if args.empty?
10
+ err.puts "Error: No specs given"
11
+ return 1
12
+ end
13
+
14
+ if args.include?('-h') || args.include?('--help')
15
+ print_help err
16
+ return 0
17
+ end
18
+
19
+ if args.include?('-v') || args.include?('--version')
20
+ print_version err
21
+ return 0
22
+ end
23
+
24
+ cmd_sw_index = args.index('-c') || args.index('--cmd')
25
+ if cmd_sw_index
26
+ specs = args.take(cmd_sw_index) # specs are before switch
27
+ cmd = args.drop(cmd_sw_index+1).join ' ' # command components are after switch, join into string to pass to subshell
28
+ if cmd.empty?
29
+ err.puts "Error: No command given"
30
+ return 2
31
+ end
32
+ else
33
+ specs = args
34
+ cmd = nil
35
+ end
36
+
37
+ results = []
38
+ specs.each do |spec|
39
+ spec = spec.strip
40
+ begin
41
+ host_spec = Iqeo::Hostspec.new spec
42
+ rescue Exception => e
43
+ err.puts "Error: #{e.message}"
44
+ return 3
45
+ end
46
+ if cmd.nil?
47
+ host_spec.each { |address| out.puts address }
48
+ else
49
+ env = {
50
+ 'HOSTSPEC_MASK' => host_spec.mask,
51
+ 'HOSTSPEC_MASKLEN' => host_spec.mask_length.to_s,
52
+ 'HOSTSPEC_COUNT' => host_spec.size.to_s
53
+ }
54
+ host_spec.each_with_index do |address,index|
55
+ env['HOSTSPEC_IP'] = address
56
+ env['HOSTSPEC_INDEX'] = (index+1).to_s
57
+ # this uses posix 'sh', to use bash user's cmd should be 'bash -c "echo \$HOSTSPEC_IP"' and deal with the weird nested quoting
58
+ results << system( env, cmd )
59
+ end
60
+ end
61
+ end
62
+ ( results.empty? || results.all? ) ? 0 : 4
63
+ end
64
+
65
+ def self.print_help io
66
+ io.puts "Usage: hostspec [ options ] specs... [ [ -c / --cmd ] command ]"
67
+ io.puts
68
+ io.puts "Prints all IP addresses for IP host specifications (see Specs:)."
69
+ io.puts "If specified, a command is executed for each IP address, with related values in environment variables (see Command:)."
70
+ io.puts
71
+ io.puts "Specs:"
72
+ io.puts " Nmap-style IP host specifications, multiple specs separated by spaces."
73
+ io.puts " Single host : x.x.x.x or hostname"
74
+ io.puts " Multiple hosts:"
75
+ io.puts " - by mask length : x.x.x.x/m or hostname/m"
76
+ io.puts " - by octet values : x.x.x.a,b,c"
77
+ io.puts " - by octet ranges : x.x.x.d-e"
78
+ io.puts " Octet values and ranges may be combined or applied to any/multiple octets."
79
+ io.puts " Examples:"
80
+ io.puts " hostname : localhost => 127.0.0.1"
81
+ io.puts " hostname w/mask : localhost/24 => 127.0.0.0 127.0.0.1 ... 127.0.0.254 127.0.0.255"
82
+ io.puts " address : 1.1.1.1 => 1.1.1.1"
83
+ io.puts " address w/mask : 2.2.2.1/24 => 2.2.2.0 2.2.2.1 ... 2.2.2.254 2.2.2.255"
84
+ io.puts " address w/values : 3.3.3.10,20,30 => 3.3.3.10 3.3.3.20 3.3.3.30"
85
+ io.puts " address w/ranges : 4.4.4.40-50 => 4.4.4.40 4.4.4.41 ... 4.4.4.49 4.4.4.50"
86
+ io.puts " address w/combo : 5.5.5.2,4-6,8 => 5.5.5.2 5.5.5.4 5.5.5.5 5.5.5.6 5.5.5.8"
87
+ io.puts " complex : 6.1-2,3.4-5.6 => 6.1.4.6 6.1.5.6 6.2.4.6 6.2.5.6 6.3.4.6 6.3.5.6"
88
+ io.puts
89
+ io.puts "Command:"
90
+ io.puts " A command to execute for each IP address may be specified following the command switch ( -c / --cmd )."
91
+ io.puts " The command is executed in a separate shell for each IP address."
92
+ io.puts " Environment variables are provided with values for each IP address command execution."
93
+ io.puts " Quote these variables in the command to prevent substitution by the current shell."
94
+ io.puts " $HOSTSPEC_IP : IP address"
95
+ io.puts " $HOSTSPEC_MASK : Mask (255.255.255.255 if a mask length was not specified)"
96
+ io.puts " $HOSTSPEC_MASKLEN : Mask length (32 if a mask length was not specified)"
97
+ io.puts " $HOSTSPEC_COUNT : Count of IP addresses"
98
+ io.puts " $HOSTSPEC_INDEX : Index of IP address (from 1 to Count)"
99
+ io.puts " Examples:"
100
+ io.puts " Print IP addresses and mask length with index and count:"
101
+ io.puts " hostspec 1.1.1.1-3 --cmd echo '$HOSTSPEC_INDEX' of '$HOSTSPECT_COUNT' : '$HOSTSPEC_IP/$HOSTSPEC_MASKLEN'"
102
+ io.puts " ..."
103
+ io.puts " 1 of 3 : 1.1.1.1/24"
104
+ io.puts " 2 of 3 : 1.1.1.2/24"
105
+ io.puts " 3 of 3 : 1.1.1.3/24"
106
+ io.puts " Collect routing tables of all hosts on a network via ssh:"
107
+ io.puts " hostspec 1.1.1.1-254 --cmd 'ssh me@$HOSTSPEC_IP route -n'"
108
+ io.puts " Collect default web pages from all servers on a network via curl:"
109
+ io.puts " hostspec 1.1.1.1-254 --cmd curl -o '$HOSTSPEC_IP.html' 'http://$HOSTSPEC_IP'"
110
+ io.puts " Collect IP configuration info from multiple windows systems (run from a windows system):"
111
+ io.puts " hostspec 1.1.1.1-254 --cmd psexec '\\%HOSTSPEC_IP%' ipconfig /all"
112
+ io.puts " Collect IP configuration info from multiple windows systems (run from a linux system with kerberos):"
113
+ io.puts " hostspec 1.1.1.1-254 --cmd winexe --kerberos yes //$(dig -x '$HOSTSPEC_IP' +short) ipconfig /all"
114
+ io.puts " ...or any task that you would have to execute individually on multiple systems."
115
+ io.puts
116
+ io.puts "Options:"
117
+ io.puts " -h / --help : Display this helpful information"
118
+ io.puts " -v / --version : Display program version"
119
+ io.puts
120
+ end
121
+
122
+ def self.print_version io
123
+ io.puts "hostspec version #{Iqeo::Hostspec::VERSION}"
124
+ end
125
+
126
+ end
127
+
128
+ if __FILE__ == $0
129
+ exit Runner.run ARGV
130
+ end
131
+
@@ -0,0 +1,24 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib/', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'iqeo/hostspec'
5
+
6
+ Gem::Specification.new do |spec|
7
+
8
+ spec.name = "iqeo-hostspec"
9
+ spec.version = Iqeo::Hostspec::VERSION
10
+ spec.authors = ["Gerard Fowley"]
11
+ spec.email = ["gerard.fowley@iqeo.net"]
12
+ spec.description = %q{Write a gem description}
13
+ spec.summary = %q{Write a gem summary}
14
+ spec.homepage = ""
15
+ spec.license = "GPLv3"
16
+
17
+ spec.files = `git ls-files`.split($/)
18
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_development_dependency "rspec"
23
+
24
+ end
@@ -0,0 +1,180 @@
1
+ require 'resolv'
2
+
3
+ module Iqeo
4
+
5
+ class HostspecException < Exception ; end
6
+
7
+ class Hostspec
8
+
9
+ VERSION = '0.1.0.pre1'
10
+
11
+ include Enumerable
12
+
13
+ attr_reader :string, :mask, :mask_length, :address_spec, :hostname
14
+
15
+ def initialize spec_str
16
+ @string = spec_str
17
+ raise HostspecException, 'spec cannot be empty' if spec_str.empty?
18
+ host_str, mask_str = split_on_slash spec_str
19
+ raise HostspecException, 'host cannot be empty' if host_str.empty?
20
+ parse_mask mask_str
21
+ begin
22
+ parse_address_spec host_str
23
+ rescue HostspecException
24
+ parse_hostname host_str
25
+ end
26
+ raise HostspecException, 'complex spec cannot have mask length' if @mask_specified && @address_spec.any? { |octet| octet.size > 1 }
27
+ mask_address_spec
28
+ end
29
+
30
+ def split_on_slash str
31
+ case str.count '/'
32
+ when 0 then [ str.strip, '' ]
33
+ when 1 then str.strip.split '/'
34
+ else raise 'bad format, expected 0 or 1 "/"'
35
+ end
36
+ end
37
+
38
+ def parse_mask str
39
+ if str.empty?
40
+ @mask = '255.255.255.255'
41
+ @mask_length = 32
42
+ @mask_specified = false
43
+ return
44
+ end
45
+ if match = str.match( /^\d+$/ )
46
+ @mask_length = match[0].to_i
47
+ raise "bad mask length (#{@mask_length}), expected between 0 ad 32" unless @mask_length.between? 0,32
48
+ mask_int = ((2**@mask_length)-1) << (32-@mask_length)
49
+ @mask = [24,16,8,0].collect { |n| ( mask_int & ( 255 << n ) ) >> n }.join '.'
50
+ @mask_specified = true
51
+ else
52
+ raise "bad format, expected mask length after '/'"
53
+ end
54
+ end
55
+
56
+ def mask_address_spec
57
+ @address_spec.each_with_index do |octet,index|
58
+ high_bit_position = ( index * 8 ) + 1
59
+ low_bit_position = ( index + 1 ) * 8
60
+ @address_spec[index] = case
61
+ when @mask_length >= low_bit_position then octet
62
+ when @mask_length < high_bit_position then [0..255]
63
+ else
64
+ octet_mask_length = @mask_length % 8
65
+ octet_mask = ( ( 2 ** octet_mask_length ) - 1 ) << ( 8 - octet_mask_length )
66
+ octet_mask_inverted = octet_mask ^ 255
67
+ octet_min = octet_mask & octet[0]
68
+ octet_max = octet_min | octet_mask_inverted
69
+ [octet_min..octet_max]
70
+ end
71
+ end
72
+ end
73
+
74
+ def parse_address_spec str
75
+ octet_strs = str.split '.'
76
+ raise HostspecException, 'bad ip, expected 4 octets' unless octet_strs.size == 4
77
+ octets = octet_strs.collect { |octet_str| parse_octet octet_str }
78
+ @address_spec = octets
79
+ end
80
+
81
+ def parse_octet str
82
+ values = str.split ','
83
+ values.collect { |value_str| parse_octet_value value_str }
84
+ end
85
+
86
+ def parse_octet_value str
87
+ # values may be dash denoted ranges, possibilities...
88
+ # n : just a number : 'n'.split '-' == ['n'] <= same = problem! 'n'.split '-', -1 == [ "n" ]
89
+ # n-m : range from n to m : 'n-m'.split '-' == ['n','m'] 'n-m'.split '-', -1 == [ "n" , "m" ]
90
+ # n- : range from n to 255 : 'n-'.split '-' == ['n'] <= same = problem! 'n-'.split '-', -1 == [ "n" , "" ]
91
+ # -m : range from 0 to m : '-m'.split '-' == ['','m'] '-m'.split '-', -1 == [ "" , "m" ]
92
+ # - : range from 0 to 255 : '-'.split '-' == [] '-'.split '-', -1 == [ "" , "" ]
93
+ numbers = str.split '-', -1 # maximize return fields to distinguish 'n' from '-m'
94
+ case numbers.size
95
+ when 1 then
96
+ check_octet_value numbers[0]
97
+ numbers[0].to_i
98
+ when 2 then
99
+ numbers[0] = '0' if numbers[0].empty?
100
+ numbers[1] = '255' if numbers[1].empty?
101
+ check_octet_value numbers[0]
102
+ check_octet_value numbers[1]
103
+ range_start = numbers[0].to_i
104
+ range_finish = numbers[1].to_i
105
+ raise HostspecException, "bad ip, reversed range in octet value: #{str}" if range_start > range_finish
106
+ range_start..range_finish
107
+ else
108
+ raise HostspecException, "bad ip, invalid octet value: #{str}"
109
+ end
110
+ end
111
+
112
+ def check_octet_value str
113
+ match = str.match /^(25[0-5]|2[0-4]\d|[0-1]\d\d|\d\d|\d)$/
114
+ raise HostspecException, "bad ip, octet value is not a number in 0-255: #{str}" unless match
115
+ end
116
+
117
+ def parse_hostname str
118
+ @hostname = str
119
+ parse_address_spec Resolv.getaddress(str)
120
+ end
121
+
122
+ def recursively_iterate_octets octet_index = 0, address = [], &block
123
+ @address_spec[octet_index].each do |item|
124
+ if item.respond_to? :each
125
+ item.each do |value|
126
+ address.push value
127
+ octet_index == 3 ? yield( address.join '.' ) : recursively_iterate_octets( octet_index + 1, address, &block )
128
+ address.pop
129
+ end
130
+ else
131
+ address.push item
132
+ octet_index == 3 ? yield( address.join '.' ) : recursively_iterate_octets( octet_index + 1, address, &block )
133
+ address.pop
134
+ end
135
+ end
136
+ end
137
+
138
+ def each_address
139
+ if block_given?
140
+ recursively_iterate_octets do |address_str|
141
+ yield address_str
142
+ end
143
+ else
144
+ return to_enum( :each_address ) { size }
145
+ end
146
+ end
147
+
148
+ alias_method :each, :each_address
149
+
150
+ def size
151
+ if @mask_length == 32
152
+ @address_spec.inject(1) { |oc,o| oc * o.inject(0) { |vc,v| vc + ( v.respond_to?(:each) ? v.size : 1 ) } }
153
+ else
154
+ 2**(32-@mask_length)
155
+ end
156
+ end
157
+
158
+ def min
159
+ first
160
+ end
161
+
162
+ def last
163
+ address = nil
164
+ each { |addr| address = addr }
165
+ address
166
+ end
167
+
168
+ def max
169
+ last
170
+ end
171
+
172
+ def minmax
173
+ [first,last]
174
+ end
175
+
176
+ end
177
+
178
+ end
179
+
180
+
@@ -0,0 +1,109 @@
1
+ load './bin/hostspec'
2
+
3
+ require 'stringio'
4
+
5
+ describe Runner do
6
+
7
+ it 'expects an array of arguments' do
8
+ expect { Runner.run }.to raise_error
9
+ expect { Runner.run nil }.to raise_error
10
+ expect { Runner.run '1.1.1.1' }.to raise_error
11
+ expect { Runner.run [], out: StringIO.new, err: StringIO.new }.to_not raise_error
12
+ end
13
+
14
+ it 'accepts single host spec for argument with no switches' do
15
+ Runner.run( ['10.20.30.40'], out: StringIO.new, err: StringIO.new ).should eq 0
16
+ Runner.run( ['10.20.30.40/24'], out: StringIO.new, err: StringIO.new ).should eq 0
17
+ Runner.run( ['10,11.20-29.30,31-38,39.40'], out: StringIO.new, err: StringIO.new ).should eq 0
18
+ Runner.run( ['localhost'], out: StringIO.new, err: StringIO.new ).should eq 0
19
+ end
20
+
21
+ it 'accepts multiple host specs for arguments with no switches' do
22
+ Runner.run( ['10.20.30.40/24','11.22.33.44'], out: StringIO.new, err: StringIO.new ).should eq 0
23
+ Runner.run( ['10,11.20-29.30,31-38,39.40','11.22.33.44/28'], out: StringIO.new, err: StringIO.new ).should eq 0
24
+ Runner.run( ['localhost','1.2.3.4/26','11.22.33.44-55'], out: StringIO.new, err: StringIO.new ).should eq 0
25
+ end
26
+
27
+ it 'expects valid single host spec or prints error' do
28
+ error = StringIO.new
29
+ Runner.run( ['no-such-host/xyz'], out: StringIO.new, err: error ).should eq 3
30
+ error.string.should include 'Error'
31
+ end
32
+
33
+ it 'expects valid multiple host specs or prints error' do
34
+ error = StringIO.new
35
+ Runner.run( ['no-such-host/xyz','5.5.5.5'], out: StringIO.new, err: error ).should eq 3
36
+ error.string.should include 'Error'
37
+ end
38
+
39
+ it 'lists single host spec IP addresses to stdout for no command (-c/--cmd) switch' do
40
+ output = StringIO.new
41
+ Runner.run( ['1.1.1.1-5'], out: output )
42
+ output.string.should eq "1.1.1.1\n1.1.1.2\n1.1.1.3\n1.1.1.4\n1.1.1.5\n"
43
+ end
44
+
45
+ it 'lists multiple host spec IP addresses to stdout for no command (-c/--cmd) switch' do
46
+ output = StringIO.new
47
+ Runner.run( ['1.1.1.1-5','2.2.2.4,6,8'], out: output )
48
+ output.string.should eq "1.1.1.1\n1.1.1.2\n1.1.1.3\n1.1.1.4\n1.1.1.5\n2.2.2.4\n2.2.2.6\n2.2.2.8\n"
49
+ end
50
+
51
+ it 'accepts host specs for arguments before command (-c/--cmd) switch' do
52
+ Runner.run( ['1.1.1.1','2.2.2.2','-c' ,'#'], out: StringIO.new, err: StringIO.new ).should eq 0
53
+ Runner.run( ['1.1.1.1','2.2.2.2','--cmd','#'], out: StringIO.new, err: StringIO.new ).should eq 0
54
+ end
55
+
56
+ it 'expects a command argument after command (-c/--cmd) switch or prints error' do
57
+ [['-c'],['-c',nil],['-c',''],['--cmd'],['--cmd',nil],['--cmd','']].each do |args|
58
+ error = StringIO.new
59
+ Runner.run( ['1.1.1.1','2.2.2.2',*args], out: StringIO.new, err: error ).should eq 2
60
+ error.string.should include 'Error'
61
+ end
62
+ end
63
+
64
+ it 'prints an error message to stderr for no arguments' do
65
+ error = StringIO.new
66
+ Runner.run( [], out: StringIO.new, err: error ).should eq 1
67
+ error.string.should include 'Error'
68
+ end
69
+
70
+ it 'prints a helpful message to stderr for help (-h/--help) switch' do
71
+ [['-h'],['1.2.3.4','-h'],['-h','1.2.3.4'],['1.2.3.4','-h','5.6.7.8'],['--help'],['1.2.3.4','--help'],['--help','1.2.3.4'],['1.2.3.4','--help','5.6.7.8']].each do |args|
72
+ error = StringIO.new
73
+ Runner.run( args, out: StringIO.new, err: error ).should eq 0
74
+ error.string.should include 'hostspec'
75
+ end
76
+ end
77
+
78
+ it 'prints version info to stderr for version (-v/--version) switch' do
79
+ [['-v'],['1.2.3.4','-v'],['-v','1.2.3.4'],['1.2.3.4','-v','5.6.7.8'],['--version'],['1.2.3.4','--version'],['--version','1.2.3.4'],['1.2.3.4','--version','5.6.7.8']].each do |args|
80
+ error = StringIO.new
81
+ Runner.run( args, out: StringIO.new, err: error ).should eq 0
82
+ error.string.should include 'hostspec'
83
+ error.string.should include Iqeo::Hostspec::VERSION
84
+ end
85
+ end
86
+
87
+ it 'executes the argument after the command (-c/--cmd) switch as a shell command' do
88
+ File.delete 'test.txt' if File.exists? 'test.txt'
89
+ Runner.run( ['1.1.1.1-3','2.2.2.1-3','-c','echo X >> test.txt'] ).should eq 0
90
+ File.read('test.txt').should eq "X\n" * 6
91
+ File.delete 'test.txt' if File.exists? 'test.txt'
92
+ end
93
+
94
+ it 'provides hostspec values to command via environment variables $HOSTSPEC_IP, $HOSTSPEC_MASK, $HOSTSPEC_MASKLEN, $HOSTSPEC_COUNT, $HOSTSPEC_INDEX' do
95
+ File.delete 'test.txt' if File.exists? 'test.txt'
96
+ Runner.run( ['1.1.1.1-3','2.2.2.1-3','-c','echo $HOSTSPEC_IP $HOSTSPEC_MASK $HOSTSPEC_MASKLEN $HOSTSPEC_COUNT $HOSTSPEC_INDEX >> test.txt'] )
97
+ File.read('test.txt').should eq "1.1.1.1 255.255.255.255 32 3 1\n1.1.1.2 255.255.255.255 32 3 2\n1.1.1.3 255.255.255.255 32 3 3\n2.2.2.1 255.255.255.255 32 3 1\n2.2.2.2 255.255.255.255 32 3 2\n2.2.2.3 255.255.255.255 32 3 3\n"
98
+ File.delete 'test.txt' if File.exists? 'test.txt'
99
+ end
100
+
101
+ it 'returns 0 on successful execution of commands' do
102
+ Runner.run( ['1.1.1.1-10','-c','echo'] ).should eq 0
103
+ end
104
+
105
+ it 'returns 4 on failure of execution of any command' do
106
+ Runner.run( ['1.1.1.1-10','-c',"no-way-this-is-a-valid-command-#{rand(99999999)}"] ).should eq 4
107
+ end
108
+
109
+ end