iparty 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.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IParty
4
+ Config = Struct.new(
5
+ :account_id,
6
+ :license_key,
7
+ :mirror,
8
+ :directory,
9
+ :editions,
10
+ :eager_load,
11
+ :singletons,
12
+ :local_ip_alias,
13
+ :ipv6_significant,
14
+ :url_to_mmdb,
15
+ :annotations,
16
+ keyword_init: true,
17
+ ) do
18
+ def singletons=(val)
19
+ self[:singletons] = val
20
+ init_singletons! if val == true
21
+ end
22
+
23
+ def init_singletons!
24
+ self[:singletons] = {} if !self[:singletons] || self[:singletons] == true
25
+
26
+ editions.each {|edition| IParty::MaxMind.db(edition) }
27
+ self[:singletons]
28
+ end
29
+
30
+ def env_value *args, **kw, &block
31
+ IParty.env_value(*args, **kw, &block)
32
+ end
33
+
34
+ def annotate *args, **kw
35
+ self[:annotations] ||= {}
36
+ result = {}
37
+ IParty.expand_hostnames(args).each do |net|
38
+ ipp = IParty(net)
39
+ kw[:tags] = ((result[ipp] || self[:annotations][ipp] || {})[:tags] || []) | kw[:tags] if kw.key?(:tags)
40
+ result[ipp] = (result[ipp] || self[:annotations][ipp] || {}).merge(kw)
41
+ end
42
+ self[:annotations].merge!(result)
43
+ result
44
+ end
45
+
46
+ def annotate_tag tags, *args
47
+ self[:annotations] ||= {}
48
+ IParty.expand_hostnames(args).to_h do |net|
49
+ ipp = IParty(net)
50
+ self[:annotations][ipp] ||= {}
51
+ self[:annotations][ipp][:tags] ||= []
52
+ self[:annotations][ipp][:tags] |= Array(tags)
53
+ [net, self[:annotations][ipp][:tags]]
54
+ end
55
+ end
56
+ end
57
+
58
+ class << self
59
+ attr_accessor :config
60
+
61
+ def configure
62
+ yield(config)
63
+ end
64
+
65
+ def env_value key, default = nil, &vproc
66
+ env_value = ENV.fetch(key, default)
67
+
68
+ env_value = case env_value
69
+ when "1", "true", "on", "yes" then true
70
+ when "0", "false", "off", "no" then false
71
+ when "" then nil
72
+ else env_value
73
+ end
74
+
75
+ vproc ? vproc.call(env_value) : env_value
76
+ end
77
+
78
+ def with_config to_merge = {}, &block
79
+ config_was = @config
80
+ new_config = Config.new(config_was.to_h.merge(to_merge))
81
+
82
+ if block
83
+ begin
84
+ block.call(self.config = new_config)
85
+ ensure
86
+ self.config = config_was
87
+ end
88
+ else
89
+ new_config
90
+ end
91
+ end
92
+
93
+ def default_config
94
+ Config.new(
95
+ # If set to false drop last half of v6-addresses as they are insignificant for most applications.
96
+ # The then 64-bit addresses also fit into unsigned bigints allowing for easy range representations.
97
+ # Each IParty::Address can overwrite this with
98
+ # * #ipv6_significant accessors
99
+ # * significant: keyword (affected methods only but including new/initialize)
100
+ ipv6_significant: env_value("IPARTY_IPV6_SIGNIFICANT", true),
101
+
102
+ # Whether to use the low memory file reader or load mmdb into memory as a whole (see docs/BENCHMARK.md)
103
+ eager_load: env_value("IPARTY_EAGER_LOAD", false),
104
+
105
+ # Singleton instances (may be false, true, hash or proc which returns hash-like)
106
+ singletons: env_value("IPARTY_SINGLETONS", false),
107
+
108
+ # An IP that is used instead of local IPs
109
+ local_ip_alias: env_value("IPARTY_LOCAL_IP_ALIAS", nil),
110
+
111
+ # MaxMind account_id and license_key aka mirror basic-auth
112
+ account_id: env_value("MAXMIND_ACCOUNT_ID", nil),
113
+ license_key: env_value("MAXMIND_LICENSE_KEY", nil),
114
+
115
+ # Mirror to download tar.gz compressed mmdb-files from
116
+ mirror: env_value("MAXMIND_MIRROR", "https://download.maxmind.com/geoip/databases/:edition/download?suffix=tar.gz"),
117
+
118
+ # The mmdb editions to fetch, you don't really need country if you have city
119
+ editions: env_value("MAXMIND_EDITIONS", "GeoLite2-ASN GeoLite2-Country GeoLite2-City") {|v| v.split(/\s+|,\s*/) },
120
+
121
+ # Directory to store mmdb-files in, also creates .updating subdirectory
122
+ directory: env_value("IPARTY_DIRECTORY", nil) do |dir|
123
+ if dir && !dir.empty?
124
+ Pathname.new(dir)
125
+ else
126
+ Pathname.new(Dir.tmpdir).join("iparty")
127
+ end
128
+ end,
129
+
130
+ # Proc to turn URL into mmdb-file(s) inside target directory.
131
+ # All .mmdb files will be moved and then dir gets removed.
132
+ url_to_mmdb: proc do |url, dir, config|
133
+ auth = %{-u "#{config.account_id}:#{config.license_key}"} if config.account_id && config.license_key
134
+ curl = %{curl -L -s #{"#{auth} " if auth}"#{url}"}
135
+ tar = %{tar xz --strip-components 1 --exclude "*.txt" --no-same-owner -C #{dir.to_s.shellescape}}
136
+ system("#{curl} | #{tar}")
137
+ end,
138
+ )
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lazy_reader"
4
+ require_relative "eager_reader"
5
+ require_relative "result"
6
+
7
+ module IParty
8
+ class MaxMind
9
+ class Database
10
+ class Error < IParty::Error; end
11
+ class InvalidFileFormatError < Error; end
12
+
13
+ METADATA_BEGIN_MARKER = "#{[0xAB, 0xCD, 0xEF].pack("C*")}MaxMind.com".encode("ascii-8bit", "ascii-8bit").freeze
14
+ DATA_SECTION_SEPARATOR_SIZE = 16
15
+ SIZE_BASE_VALUES = [0, 29, 285, 65_821].freeze
16
+ POINTER_BASE_VALUES = [0, 0, 2048, 526_336].freeze
17
+
18
+ attr_reader :metadata
19
+
20
+ def initialize(path, reader: EagerReader)
21
+ @path = path
22
+ @data = reader.new(path)
23
+
24
+ pos = @data.rindex(METADATA_BEGIN_MARKER) || raise(InvalidFileFormatError, "invalid file format")
25
+ pos += METADATA_BEGIN_MARKER.size
26
+ @metadata = decode(0, pos)[1]
27
+
28
+ @ip_version = @metadata[:ip_version]
29
+ @start_idx = @ip_version == 4 ? 96 : 0
30
+ @node_count = @metadata[:node_count]
31
+ @node_byte_size = @metadata[:record_size] * 2 / 8
32
+ @search_tree_size = @node_count * @node_byte_size
33
+ @data_section_start = @search_tree_size + DATA_SECTION_SEPARATOR_SIZE
34
+ end
35
+
36
+ def close
37
+ @data.close
38
+ end
39
+
40
+ def closed?
41
+ @data.closed?
42
+ end
43
+
44
+ def inspect
45
+ "#<#{self.class}:#{format("0x%x", object_id << 1)}: @path:#{@path} @metadata:#{@metadata}>"
46
+ end
47
+
48
+ def lookup(addr, result_class: Result::Geo)
49
+ addr = IPAddr.new(addr) unless addr.is_a?(IPAddr)
50
+ addr = IParty.config.local_ip_alias if IParty.config.local_ip_alias && addr.loopback?
51
+ addr = IPAddr.new(addr) unless addr.is_a?(IPAddr)
52
+ addr = addr.ipv4_compat if addr.ipv4?
53
+ long = addr.is_a?(IParty::Address) ? addr.to_i(significant: true) : addr.to_i
54
+ node_no = 0
55
+
56
+ (@start_idx...128).each do |i|
57
+ flag = (long >> (127 - i)) & 1
58
+ next_node_no = read_record(node_no, flag)
59
+
60
+ if next_node_no == 0
61
+ raise(InvalidFileFormatError, "invalid file format")
62
+ elsif next_node_no >= @node_count
63
+ pos = (next_node_no - @node_count) - DATA_SECTION_SEPARATOR_SIZE
64
+ result = decode(@data_section_start, pos)[1]
65
+ result[:network] = cidr_from_long(long, i) unless result.empty?
66
+ return result_class.new(result)
67
+ else
68
+ node_no = next_node_no
69
+ end
70
+ end
71
+
72
+ raise(InvalidFileFormatError, "invalid file format")
73
+ end
74
+
75
+ def cidr_from_long(long, net)
76
+ addr = IPAddr.new(long, long > (2**32) - 1 ? Socket::AF_INET6 : Socket::AF_INET)
77
+ subnet_size = addr.ipv4? ? net - 96 + 1 : net + 1
78
+ subnet = IPAddr.new("#{addr}/#{subnet_size}")
79
+
80
+ "#{subnet}/#{subnet_size}"
81
+ end
82
+
83
+ def read_record(node_no, flag)
84
+ rec_byte_size = @node_byte_size / 2
85
+ pos = @node_byte_size * node_no
86
+ middle = @data[pos + rec_byte_size].ord if @node_byte_size.odd?
87
+
88
+ if flag == 0 # left
89
+ val = read_value(pos, 0, rec_byte_size)
90
+ val += ((middle & 0xf0) << 20) if middle
91
+ else # right
92
+ val = read_value(pos + @node_byte_size - rec_byte_size, 0, rec_byte_size)
93
+ val += ((middle & 0xf) << 24) if middle
94
+ end
95
+
96
+ val
97
+ end
98
+
99
+ def read_value(base_pos, pos, size)
100
+ bytes = @data[base_pos + pos, size].unpack("C*")
101
+ bytes.inject(0){|r, v| (r << 8) + v }
102
+ end
103
+
104
+ # rubocop:disable Metrics/CyclomaticComplexity -- we could reduce this, sacrificing the neat overview
105
+ def decode base_pos, pos
106
+ ctrl = @data[base_pos + pos].ord
107
+ pos += 1
108
+ type = ctrl >> 5
109
+
110
+ if type == 1 # pointer
111
+ decode_pointer(base_pos, pos, ctrl)
112
+ else
113
+ if type == 0 # extended type
114
+ type = 7 + @data[base_pos + pos].ord
115
+ pos += 1
116
+ end
117
+
118
+ size = ctrl & 0x1f
119
+ if size >= 29
120
+ byte_size = size - 29 + 1
121
+ val = read_value(base_pos, pos, byte_size)
122
+ pos += byte_size
123
+ size = val + SIZE_BASE_VALUES[byte_size]
124
+ end
125
+
126
+ # rubocop:disable Lint/DuplicateBranch -- readable order is more important
127
+ case type
128
+ when 2 # utf8
129
+ decode_utf8(base_pos, pos, size)
130
+ when 3 # double
131
+ decode_double(base_pos, pos, size)
132
+ when 4 # bytes
133
+ decode_bytes(base_pos, pos, size)
134
+ when 5 # unsigned 16-bit int
135
+ decode_uint(base_pos, pos, size)
136
+ when 6 # unsigned 32-bit int
137
+ decode_uint(base_pos, pos, size)
138
+ when 7 # map
139
+ decode_map(base_pos, pos, size)
140
+ when 8 # signed 32-bit int
141
+ decode_int(base_pos, pos, size)
142
+ when 9 # unsigned 64-bit int
143
+ decode_uint(base_pos, pos, size)
144
+ when 10 # unsigned 128-bit int
145
+ decode_uint(base_pos, pos, size)
146
+ when 11 # array
147
+ decode_array(base_pos, pos, size)
148
+ when 12 # (deprecated) data cache container
149
+ raise "TODO: (deprecated) data cache container format"
150
+ when 13 # (deprecated) end marker
151
+ [pos, nil]
152
+ when 14 # boolean
153
+ [pos, size != 0]
154
+ when 15 # float
155
+ decode_float(base_pos, pos, size)
156
+ end
157
+ # rubocop:enable Lint/DuplicateBranch
158
+ end
159
+ end
160
+ # rubocop:enable Metrics/CyclomaticComplexity
161
+
162
+ def decode_double base_pos, pos, size
163
+ [pos + size, @data[base_pos + pos, size].unpack1("G")]
164
+ end
165
+
166
+ def decode_bytes base_pos, pos, size
167
+ [pos + size, @data[base_pos + pos, size]]
168
+ end
169
+
170
+ def decode_int base_pos, pos, size
171
+ v1 = @data[base_pos + pos, size].unpack1("N")
172
+ bits = size * 8
173
+ [pos + size, (v1 & ~(1 << bits)) - (v1 & (1 << bits))]
174
+ end
175
+
176
+ def decode_uint base_pos, pos, size
177
+ [pos + size, read_value(base_pos, pos, size)]
178
+ end
179
+
180
+ def decode_float base_pos, pos, size
181
+ [pos + size, @data[base_pos + pos, size].unpack1("g")]
182
+ end
183
+
184
+ def decode_utf8 base_pos, pos, size
185
+ [pos + size, @data[base_pos + pos, size].encode("utf-8", "utf-8")]
186
+ end
187
+
188
+ def decode_pointer base_pos, pos, ctrl
189
+ size = ((ctrl >> 3) & 0x3) + 1
190
+ v1 = ctrl & 0x7
191
+ v2 = read_value(base_pos, pos, size)
192
+
193
+ pointer = (v1 << (8 * size)) + v2 + POINTER_BASE_VALUES[size]
194
+ [pos + size, decode(base_pos, pointer)[1]]
195
+ end
196
+
197
+ def decode_array base_pos, pos, size
198
+ ary = Array.new(size) do
199
+ pos, v = decode(base_pos, pos)
200
+ v
201
+ end
202
+ [pos, ary]
203
+ end
204
+
205
+ def decode_map base_pos, pos, size
206
+ map = {}
207
+ size.times do
208
+ pos, k = decode(base_pos, pos)
209
+ pos, v = decode(base_pos, pos)
210
+ map[k.to_sym] = v
211
+ end
212
+ [pos, map]
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IParty
4
+ class MaxMind
5
+ # A fast read-access reader for MaxMindDB (mmdb) files. Reads the database into memory.
6
+ # This creates a higher memory overhead and slower init phase but faster lookup times.
7
+ class EagerReader
8
+ def initialize path
9
+ @data = File.binread(path)
10
+ end
11
+
12
+ def [] pos, length = 1
13
+ raise IOError, "closed stream" unless @data
14
+
15
+ @data.slice(pos, length)
16
+ end
17
+
18
+ def rindex search
19
+ raise IOError, "closed stream" unless @data
20
+
21
+ @data.rindex(search)
22
+ end
23
+
24
+ def close
25
+ @data = nil
26
+ end
27
+
28
+ def closed?
29
+ @data.nil?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IParty
4
+ class MaxMind
5
+ # A low memory file reader for MaxMindDB (mmdb) files. Avoids reading the database into memory.
6
+ # Has a lower memory footprint but slower lookup times.
7
+ class LazyReader
8
+ METADATA_MAX_SIZE = 128 * 1024
9
+
10
+ def initialize path
11
+ @mutex = Mutex.new
12
+ @file = File.open(path, "rb")
13
+ end
14
+
15
+ def [] pos, length = 1
16
+ atomic_read(length, pos)
17
+ end
18
+
19
+ def close
20
+ @file.close
21
+ end
22
+
23
+ def closed?
24
+ @file.closed?
25
+ end
26
+
27
+ def rindex search
28
+ base = [0, @file.size - METADATA_MAX_SIZE].max
29
+ tail = atomic_read(METADATA_MAX_SIZE, base)
30
+ pos = tail.rindex(search)
31
+ base + pos unless pos.nil?
32
+ end
33
+
34
+ def atomic_read length, pos
35
+ # Prefer `pread` in environments where it is available. `pread` provides atomic file access across processes.
36
+ if @file.respond_to?(:pread)
37
+ @file.pread(length, pos)
38
+ else
39
+ @mutex.synchronize do
40
+ @file.seek(pos)
41
+ @file.read(length)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IParty
4
+ class MaxMind
5
+ class Result < Hash
6
+ class << self
7
+ def define_attr(name, attribute = name, type: nil, aliases: nil, memoize: nil, export: nil, &transform)
8
+ ivar = :"@#{attribute}"
9
+ transform ||= ->(v) { type.new(v) } if type
10
+ memoize = true if memoize.nil? && transform
11
+ method_names = [name] + Array(aliases)
12
+
13
+ method_names.each do |meth|
14
+ define_method(meth) do
15
+ value = dig(attribute)
16
+ return transform ? instance_exec(value, &transform) : value unless memoize
17
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
18
+
19
+ instance_variable_set(ivar, instance_exec(value, &transform))
20
+ end
21
+ end
22
+
23
+ Array(export).each{|exp| export_attr(name, exp == true ? name : exp) }
24
+
25
+ method_names
26
+ end
27
+
28
+ def export_attr name, export
29
+ if self == Result::Asn
30
+ IParty::Address.def_delegator(:asn, name, export)
31
+ elsif self == Result::Geo || self == Result
32
+ IParty::Address.def_delegator(:geo, name, export)
33
+ else
34
+ raise ArgumentError, "you can only export on Result, Geo(*) and Asn (got #{self})"
35
+ end
36
+ end
37
+ end
38
+
39
+ class Location < Result
40
+ define_attr(:accuracy_radius)
41
+ define_attr(:latitude)
42
+ define_attr(:longitude)
43
+ define_attr(:metro_code)
44
+ define_attr(:time_zone)
45
+ end
46
+
47
+ class NamedLocation < Result
48
+ define_attr(:code)
49
+ define_attr(:geoname_id)
50
+ define_attr(:is_in_european_union, aliases: :in_european_union?)
51
+ define_attr(:iso_code)
52
+ define_attr(:names, type: Result)
53
+
54
+ def name(locale = :en, fallback_locale: :en)
55
+ return unless all = dig(:names)
56
+
57
+ all[locale] || (all[fallback_locale] if fallback_locale)
58
+ end
59
+
60
+ # dynamic cast
61
+ alias_method :to_s, :name
62
+ alias_method :to_i, :geoname_id
63
+
64
+ # dynamic comparison
65
+ def == other
66
+ case other
67
+ when String
68
+ name && other == name
69
+ when Numeric
70
+ geoname_id && other == geoname_id
71
+ else
72
+ super
73
+ end
74
+ end
75
+
76
+ # dynamic inquiry
77
+ define_attr(:inquire_on_name) { name&.downcase&.tr(" ", "_") }
78
+ define_attr(:inquire_on_code) { (iso_code || code)&.downcase }
79
+
80
+ def respond_to_missing? method_name, include_private = false
81
+ method_name.end_with?("?") || super
82
+ end
83
+
84
+ def method_missing method_name, *arguments
85
+ if method_name.end_with?("?")
86
+ method_name[0..-2] == (method_name.length == 3 ? inquire_on_code : inquire_on_name)
87
+ else
88
+ super
89
+ end
90
+ end
91
+ end
92
+
93
+ class City < NamedLocation
94
+ end
95
+
96
+ class Continent < NamedLocation
97
+ end
98
+
99
+ class Country < NamedLocation
100
+ end
101
+
102
+ class Subdivision < NamedLocation
103
+ end
104
+
105
+ class Subdivisions < Array
106
+ def initialize raw
107
+ super((raw || []).map{|hash| Subdivision.new(hash) })
108
+ end
109
+
110
+ def inspect
111
+ "#<#{self.class}:#{format("0x%x", object_id << 1)}: #{super}>"
112
+ end
113
+
114
+ alias_method :blank?, :empty?
115
+
116
+ def present?
117
+ !empty?
118
+ end
119
+
120
+ def presence
121
+ self if present?
122
+ end
123
+
124
+ def least_specific
125
+ first || Subdivision.new
126
+ end
127
+
128
+ def most_specific
129
+ last || Subdivision.new
130
+ end
131
+ end
132
+
133
+ class Postal < Result
134
+ define_attr(:code)
135
+ end
136
+
137
+ class Traits < Result
138
+ define_attr(:is_anonymous_proxy, aliases: :anonymous_proxy?)
139
+ define_attr(:is_satellite_provider, aliases: :satellite_provider?)
140
+ end
141
+
142
+ # ------------------------------------------
143
+
144
+ class Asn < Result
145
+ def initialize(data = {})
146
+ data ? super({ autonomous_system_network: data[:network] }.compact.merge(data)) : super()
147
+ end
148
+
149
+ define_attr(:autonomous_system_network, aliases: :network, export: true)
150
+ define_attr(:autonomous_system_number, aliases: :number, export: true)
151
+ define_attr(:autonomous_system_organization, aliases: :organization, export: true)
152
+ define_attr(:autonomous_system_detailed, aliases: :detailed, export: true) { "AS#{number} #{organization}" if number }
153
+ end
154
+
155
+ class Geo < Result
156
+ define_attr(:city, type: City, export: true)
157
+ define_attr(:connection_type)
158
+ define_attr(:continent, type: Continent, export: true)
159
+ define_attr(:country, type: Country, export: true)
160
+ define_attr(:location, type: Location)
161
+ define_attr(:network)
162
+ define_attr(:postal, type: Postal)
163
+ define_attr(:registered_country, type: Country)
164
+ define_attr(:represented_country, type: Country)
165
+ define_attr(:subdivisions, type: Subdivisions)
166
+ define_attr(:traits, type: Traits)
167
+
168
+ define_attr(:accuracy_radius, memoize: false) { location.accuracy_radius }
169
+ define_attr(:is_in_european_union, aliases: :in_european_union?, export: :in_european_union?, memoize: false) { country.is_in_european_union }
170
+ define_attr(:latitude, memoize: false, export: true) { location.latitude }
171
+ define_attr(:longitude, memoize: false, export: true) { location.longitude }
172
+ define_attr(:metro_code, memoize: false) { location.metro_code }
173
+ define_attr(:time_zone, memoize: false, export: true) { location.time_zone }
174
+ define_attr(:postal_code, aliases: :zip, export: true, memoize: false) { postal.code }
175
+
176
+ define_attr(:detailed_parts, memoize: false) { [continent.code, country.name, city.name].compact }
177
+ define_attr(:detailed, memoize: false) { detailed_parts.join(" / ") }
178
+ end
179
+
180
+ class GeoCountry < Geo; end
181
+ class GeoCity < Geo; end
182
+
183
+ # ------------------------------------------
184
+
185
+ def initialize(data = {})
186
+ super()
187
+ merge!(data) if data
188
+ end
189
+
190
+ def inspect
191
+ "#<#{self.class}:#{format("0x%x", object_id << 1)}: #{super}>"
192
+ end
193
+
194
+ alias_method :blank?, :empty?
195
+
196
+ def present?
197
+ !empty?
198
+ end
199
+
200
+ def presence
201
+ self if present?
202
+ end
203
+ end
204
+ end
205
+ end