macker 0.1.0

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/lib/macker.rb ADDED
@@ -0,0 +1,394 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'ostruct'
4
+ require 'open-uri'
5
+ require 'macker/version'
6
+ require 'macker/config'
7
+ require 'macker/address'
8
+
9
+ # Macker namespace
10
+ module Macker
11
+ # Invalid cache, file not found or cache empty
12
+ class InvalidCache < StandardError; end
13
+ # Invalid raw data, file not found or cache empty
14
+ class InvalidRawData < StandardError; end
15
+ # Invalid options, invalid options given to method
16
+ class InvalidOptions < StandardError; end
17
+ # Invalid OUI vendor, vendor not found in table
18
+ class NotFoundOuiVendor < StandardError; end
19
+
20
+ # Extend instance methods to class methods
21
+ extend self
22
+
23
+ # Proc timestamp accessor to set external timestamp
24
+ # @param value [Time] proc timestamp
25
+ # @return [Time] proc timestamp
26
+ attr_accessor :proc_timestamp
27
+
28
+ # Get the timestamp of vendor list in memory
29
+ # @return [Time] time object or nil
30
+ attr_reader :mem_timestamp
31
+
32
+ # Get or initialize Macker config.
33
+ # @return [OpenStruct] Macker configuration
34
+ def config
35
+ @config ||= Macker::Config.new
36
+ end
37
+
38
+ # Set configuration of Macker in a block.
39
+ # @return [OpenStruct] Macker configuration
40
+ def configure
41
+ yield config if block_given?
42
+ end
43
+
44
+ # Update all OUI tables
45
+ # @param straight [Boolean] true for straight, default is careful
46
+ # @return [Time] timestamp of the update
47
+ def update(straight = false)
48
+ @prefix_table = {}
49
+ @iso_code_table = {}
50
+ @vendor_table = {}
51
+ vendor_list(straight)
52
+ @mem_timestamp = if config.cache.is_a?(Proc)
53
+ proc_timestamp
54
+ else
55
+ file_timestamp
56
+ end
57
+ end
58
+
59
+ # Lookup for a vendor with given MAC address.
60
+ #
61
+ # @example
62
+ # lookup('00:04:A9:6D:B8:AC')
63
+ # lookup(20022409388)
64
+ #
65
+ # @param mac [Address,Integer,String] MAC address
66
+ # @param opts [Hash] options for the method
67
+ # @return [Address] MAC address with vendor data
68
+ def lookup(mac, opts = {})
69
+ expire! if config.auto_expiration
70
+ data = prefix_table[Address.new(mac).prefix]
71
+ if data.nil?
72
+ opts[:raising] ? raise(NotFoundOuiVendor, "OUI not found for MAC: #{mac}") : (return nil)
73
+ end
74
+ Address.new(mac, data)
75
+ end
76
+
77
+ # Lookup for a vendor with given MAC address.
78
+ # Raises an error if no vendor found.
79
+ #
80
+ # @example
81
+ # lookup!('00:04:A9:6D:B8:AC')
82
+ # lookup!('80:47:FB:B2:9E:D6')
83
+ #
84
+ # @param mac [Address,Integer,String] MAC address
85
+ # @return [Address] MAC address with vendor data
86
+ def lookup!(mac)
87
+ lookup(mac, raising: true)
88
+ end
89
+
90
+ # Generate a MAC address.
91
+ # - No options for random MAC.
92
+ # - Vendor option to get a valid OUI MAC.
93
+ # - Vendor Name option to get a random MAC from vendor.
94
+ #
95
+ # @example
96
+ # generate
97
+ # generate(vendor: true)
98
+ # generate(vendor: 'IEEE Registration Authority')
99
+ #
100
+ # @param opts [Hash] options for the method
101
+ # @return [Address] MAC address with data
102
+ def generate(opts = {})
103
+ expire! if config.auto_expiration
104
+ return generate_by_iso_code(opts.delete(:iso_code), opts) if opts[:iso_code] && opts[:iso_code].length == 2
105
+ vendor = opts.delete(:vendor)
106
+ case vendor
107
+ when nil, false
108
+ Address.new(rand(2**48))
109
+ when true
110
+ generate_by_vendor(prefix_table[prefix_table.keys.shuffle.sample][:name], opts)
111
+ when String
112
+ generate_by_vendor(vendor, opts)
113
+ else
114
+ raise(InvalidOptions, "Incompatible option vendor for generate: #{vendor.class}")
115
+ end
116
+ end
117
+
118
+ # Generate a MAC address.
119
+ # - No options for random MAC address.
120
+ # - Vendor option to get a valid OUI MAC address.
121
+ # - Vendor name option to get a random MAC address from vendor.
122
+ # Raises an error if an error occurs.
123
+ #
124
+ # @example
125
+ # generate
126
+ # generate(vendor: true)
127
+ # generate(vendor: 'No vendor')
128
+ #
129
+ # @param opts [Hash] options for the method
130
+ # @return [Address] MAC address with vendor data
131
+ def generate!(opts = {})
132
+ generate(opts.merge(raising: true))
133
+ end
134
+
135
+ # Vendor table with all base16 MAC prefixes as keys
136
+ # @return [Hash] vendor prefixes table
137
+ def prefix_table
138
+ update unless @prefix_table
139
+ @prefix_table
140
+ end
141
+
142
+ # Vendor table with all country iso codes as keys
143
+ # @return [Hash] vendor iso codes table
144
+ def iso_code_table
145
+ update unless @iso_code_table
146
+ @iso_code_table
147
+ end
148
+
149
+ # Vendor table with all country vendor names as keys
150
+ # @return [Hash] vendor names table
151
+ def vendor_table
152
+ update unless @vendor_table
153
+ @vendor_table
154
+ end
155
+
156
+ # Fetch new vendor list if cached list is expired or stale
157
+ # @return [Boolean] true if vendor list is expired and updated from remote
158
+ def expire!
159
+ if expired?
160
+ update(true)
161
+ true
162
+ elsif stale?
163
+ update
164
+ true
165
+ else
166
+ false
167
+ end
168
+ end
169
+
170
+ # Check if vendor list is expired
171
+ # @return [Boolean] true if vendor list is expired
172
+ def expired?
173
+ Time.now > vendors_expiration
174
+ end
175
+
176
+ # Check if vendor list is stale
177
+ # Stale is true if vendor list is updated straight by another thread.
178
+ # The actual thread has always old vendor list in memory store.
179
+ # @return [Boolean] true if vendor list is stale
180
+ def stale?
181
+ if config.cache.is_a?(Proc)
182
+ proc_timestamp != mem_timestamp
183
+ else
184
+ file_timestamp != mem_timestamp
185
+ end
186
+ end
187
+
188
+ # Get vendor list expiration time based on ttl
189
+ # @return [Time] vendor list expiration time
190
+ def vendors_expiration
191
+ if config.cache.is_a?(Proc)
192
+ proc_timestamp + config.ttl_in_seconds
193
+ else
194
+ file_timestamp + config.ttl_in_seconds
195
+ end
196
+ end
197
+
198
+ protected
199
+
200
+ # Generate a MAC address by vendor.
201
+ #
202
+ # @param vendor [String] name of vendor
203
+ # @param opts [Hash] options for the method
204
+ # @return [Address] MAC address with vendor data
205
+ def generate_by_vendor(vendor, opts = {})
206
+ ouis = vendor_table[vendor]
207
+ if ouis.nil? || ouis.empty?
208
+ opts[:raising] ? raise(NotFoundOuiVendor, "OUI not found for vendor: #{vendor}") : (return nil)
209
+ end
210
+ oui = ouis[rand(ouis.size)]
211
+ m1 = Address.new(oui[:prefix]).to_i
212
+ m2 = rand(2**24)
213
+ mac = m1 + m2
214
+ Address.new(mac,
215
+ name: vendor,
216
+ address: oui[:address],
217
+ iso_code: oui[:iso_code])
218
+ end
219
+
220
+ # Generate a MAC address by iso code.
221
+ #
222
+ # @param iso_code [String] iso code
223
+ # @param opts [Hash] options for the method
224
+ # @return [Address] MAC address with vendor data
225
+ def generate_by_iso_code(iso_code, opts = {})
226
+ ouis = iso_code_table[iso_code]
227
+ if ouis.nil? || ouis.empty?
228
+ opts[:raising] ? raise(NotFoundOuiVendor, "OUI not found for iso code #{iso_code}") : (return nil)
229
+ end
230
+ oui = ouis[rand(ouis.size)]
231
+ m1 = Address.new(oui[:prefix]).to_i
232
+ m2 = rand(2**24)
233
+ mac = m1 + m2
234
+ Address.new(mac,
235
+ name: oui[:name],
236
+ address: oui[:address],
237
+ iso_code: iso_code)
238
+ end
239
+
240
+ # Get vendor list with different strategies.
241
+ # Parse and read in the content.
242
+ #
243
+ # @example
244
+ # vendor_list(true)
245
+ # vendor_list
246
+ #
247
+ # @param straight [Boolean] true for straight, default is careful
248
+ # @return [Hash] vendor list with all base16 MAC prefixes as keys
249
+ def vendor_list(straight = false)
250
+ raw_vendor_list = if straight
251
+ raw_vendor_list_straight
252
+ else
253
+ raw_vendor_list_careful
254
+ end
255
+ vendor_list = raw_vendor_list.gsub(/\r\n/, "\n").gsub(/\t+/, "\t").split(/\n\n/)
256
+ vendor_list[1..-1].each do |vendor|
257
+ base16_fields = vendor.strip.split("\n")[1].split("\t")
258
+ mac_prefix = Address.new(base16_fields[0].strip[0..5]).prefix
259
+ address = vendor.strip.delete("\t").split("\n")
260
+ next unless @prefix_table[mac_prefix].nil?
261
+ @prefix_table[mac_prefix] = { name: base16_fields[-1]
262
+ .strip
263
+ .gsub(/\s+/, ' ')
264
+ .split(/ |\_|\-/)
265
+ .map(&:capitalize)
266
+ .join(' '),
267
+ address: address[2..-1].map { |a| a
268
+ .strip
269
+ .gsub(/\s+/, ' ')
270
+ .gsub(/,(?![\s])/, ', ')
271
+ .gsub(/\,+$/, '')
272
+ .split(/ |\_|\-/)
273
+ .map(&:capitalize)
274
+ .join(' ')
275
+ },
276
+ iso_code: address[-1].strip.upcase
277
+ }
278
+ end
279
+ @iso_code_table = @prefix_table.each_with_object({}) { |(key, value), out| (out[value[:iso_code]] ||= []) << value.merge(prefix: key) }
280
+ @vendor_table = @prefix_table.each_with_object({}) { |(key, value), out| (out[value[:name]] ||= []) << value.merge(prefix: key) }
281
+ @prefix_table
282
+ end
283
+
284
+ # Get raw vendor list from cache and then from url
285
+ # @param rescue_straight [Boolean] true for rescue straight, default true
286
+ # @return [String] text content
287
+ def raw_vendor_list_careful(rescue_straight = true)
288
+ res = read_from_cache
289
+ raise if res.to_s.empty?
290
+ res
291
+ rescue
292
+ rescue_straight ? raw_vendor_list_straight : ''
293
+ end
294
+
295
+ # Get raw vendor list from url
296
+ # @return [String] text content
297
+ def raw_vendor_list_straight
298
+ res = read_from_url
299
+ raise if res.to_s.empty?
300
+ res
301
+ rescue
302
+ raw_vendor_list_careful(false)
303
+ end
304
+
305
+ # Store the provided text data by calling the proc method provided
306
+ # for the cache, or write to the cache file.
307
+ #
308
+ # @example
309
+ # store_in_cache("E0-43-DB (hex) Shenzhen ViewAt Technology Co.,Ltd.
310
+ # E043DB (base 16) Shenzhen ViewAt Technology Co.,Ltd.
311
+ # 9A,Microprofit,6th Gaoxin South...
312
+ # shenzhen guangdong 518057
313
+ # CN
314
+ # ")
315
+ #
316
+ # @param text [String] text content
317
+ # @return [Integer] normally 0
318
+ def store_in_cache(text)
319
+ if config.cache.is_a?(Proc)
320
+ config.cache.call(text)
321
+ elsif config.cache.is_a?(String) || config.cache.is_a?(Pathname)
322
+ write_to_file(text)
323
+ end
324
+ end
325
+
326
+ # Writes content to file cache
327
+ # @param text [String] text content
328
+ # @return [Integer] normally 0
329
+ def write_to_file(text)
330
+ open(file_path, 'w') do |f|
331
+ f.write(text)
332
+ end
333
+ file_update
334
+ rescue Errno::ENOENT
335
+ raise InvalidCache
336
+ end
337
+
338
+ # Read from cache when exist
339
+ # @return [Proc,String] text content
340
+ def read_from_cache
341
+ if config.cache.is_a?(Proc)
342
+ config.cache.call(nil)
343
+ elsif (config.cache.is_a?(String) || config.cache.is_a?(Pathname)) &&
344
+ File.exist?(file_path)
345
+ open(file_path).read
346
+ end
347
+ end
348
+
349
+ # Get remote content and store in cache
350
+ # @return [String] text content
351
+ def read_from_url
352
+ text = open_url
353
+ store_in_cache(text) if text && config.cache
354
+ text
355
+ end
356
+
357
+ # Opens an URL and reads the content
358
+ # @return [String] text content
359
+ def open_url
360
+ opts = [config.oui_full_url]
361
+ opts << { 'User-Agent' => config.user_agent } if config.user_agent
362
+ open(*opts).read
363
+ rescue OpenURI::HTTPError
364
+ ''
365
+ end
366
+
367
+ # Get file path with timestamp
368
+ # @return [String] file path
369
+ def file_path
370
+ Dir.glob(config.cache).first || File.join(File.dirname(config.cache),
371
+ File.basename(config.cache).gsub(/_.+\.txt/, '_0.txt'))
372
+ end
373
+
374
+ # Get file name with timestamp
375
+ # @return [String] file name
376
+ def file_name
377
+ File.basename(file_path)
378
+ end
379
+
380
+ # Get file timestamp
381
+ # @return [Time] file timestamp
382
+ def file_timestamp
383
+ timestamp = file_name.scan(/\d+/).first
384
+ timestamp ? Time.at(timestamp.to_i) : Time.at(0)
385
+ end
386
+
387
+ # Update file name timestamp
388
+ # @return [Integer] normally 0
389
+ def file_update
390
+ File.rename(file_path,
391
+ File.join(File.dirname(file_path),
392
+ File.basename(file_path).gsub(/_\d+\.txt/, "_#{Time.now.to_i}.txt")))
393
+ end
394
+ end
@@ -0,0 +1,137 @@
1
+ # encoding: UTF-8
2
+
3
+ # Macker namespace
4
+ module Macker
5
+ # Invalid address, mac address format not valid
6
+ class InvalidAddress < StandardError; end
7
+ # MAC address class
8
+ class Address
9
+ include Comparable
10
+
11
+ # Get the value of name, address or iso code
12
+ # @return [String] content of the value
13
+ attr_reader :name, :address, :iso_code
14
+
15
+ # Initialize Address object
16
+ # @param mac [Address,Integer,String] a MAC address
17
+ # @param opts [Hash] options for the method
18
+ # @return [Address] the initialized object
19
+ def initialize(mac, opts = {})
20
+ case mac
21
+ when Address
22
+ @val = mac.to_i
23
+ @name = mac.name
24
+ @address = mac.address
25
+ @iso_code = mac.iso_code
26
+ when Integer
27
+ @val = mac
28
+ when String
29
+ @val = cleanup(mac).to_i(16)
30
+ else
31
+ raise(InvalidAddress, "Incompatible type for address initialization: #{mac.class}")
32
+ end
33
+ raise(InvalidAddress, "Invalid MAC address: #{self.to_s}") unless valid?
34
+ @name ||= opts.fetch(:name, nil)
35
+ @address ||= opts.fetch(:address, nil)
36
+ @iso_code ||= opts.fetch(:iso_code, nil)
37
+ end
38
+
39
+ # Format MAC address to integer
40
+ # @return [Integer] integer MAC address
41
+ def to_i
42
+ @val
43
+ end
44
+
45
+ # Format MAC address to string
46
+ # @param sep [String] separator, default is ':'
47
+ # @return [String] formatted MAC address
48
+ def to_s(sep = ':')
49
+ @val.to_s(16)
50
+ .rjust(12, '0')
51
+ .insert(10, sep)
52
+ .insert(8, sep)
53
+ .insert(6, sep)
54
+ .insert(4, sep)
55
+ .insert(2, sep)
56
+ .upcase
57
+ end
58
+
59
+ # Compare two MAC addresses
60
+ # @param other [Address] MAC address object
61
+ # @return [Boolean] true if the same, else false
62
+ def <=>(other)
63
+ @val <=> other.to_i
64
+ end
65
+
66
+ # Check if MAC address is an OUI valid address
67
+ # @return [Boolean] true if valid, else false
68
+ def oui?
69
+ !@name.nil?
70
+ end
71
+
72
+ # Check if MAC address is a valid address
73
+ # @return [Boolean] true if valid, else false
74
+ def valid?
75
+ @val.between?(0, 2**48 - 1)
76
+ end
77
+
78
+ # Check if MAC address is a broadcast address
79
+ # @return [Boolean] true if broadcast, else false
80
+ def broadcast?
81
+ @val == 2**48 - 1
82
+ end
83
+
84
+ # Check if MAC address is an unicast address
85
+ # @return [Boolean] true if unicast, else false
86
+ def unicast?
87
+ !multicast?
88
+ end
89
+
90
+ # Check if MAC address is a multicast address
91
+ # @return [Boolean] true if multicast, else false
92
+ def multicast?
93
+ mask = 1 << (5 * 8)
94
+ (mask & @val) != 0
95
+ end
96
+
97
+ # Check if MAC address is a global uniq address
98
+ # @return [Boolean] true if uniq, else false
99
+ def global_uniq?
100
+ !local_admin?
101
+ end
102
+
103
+ # Check if MAC address is a local address
104
+ # @return [Boolean] true if local, else false
105
+ def local_admin?
106
+ mask = 2 << (5 * 8)
107
+ (mask & @val) != 0
108
+ end
109
+
110
+ # Get next MAC address from actual address
111
+ # @return [Adress] next MAC address
112
+ def next
113
+ Address.new((@val + 1) % 2**48)
114
+ end
115
+ alias succ next
116
+
117
+ # Get the prefix base16 MAC address
118
+ # @return [Adress] MAC prefix
119
+ def prefix
120
+ to_s('')[0..5]
121
+ end
122
+
123
+ # Get the full vendor address
124
+ # @return [String] full vendor address string
125
+ def full_address
126
+ address.join(', ')
127
+ end
128
+
129
+ private
130
+
131
+ # Clean up a MAC string from special characters
132
+ # @return [String] cleaned MAC address
133
+ def cleanup(mac)
134
+ mac.strip.upcase.gsub(/^0[xX]/, '').gsub(/[^0-9A-F]/, '').ljust(12, '0')
135
+ end
136
+ end
137
+ end