ip_geo_lookup 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +156 -0
- data/lib/ip_geo_lookup/ip_address.rb +69 -0
- data/lib/ip_geo_lookup/mmdb.rb +264 -0
- data/lib/ip_geo_lookup/result.rb +107 -0
- data/lib/ip_geo_lookup/version.rb +5 -0
- data/lib/ip_geo_lookup.rb +109 -0
- metadata +59 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 52397b7053c8f2c5428972a08b16b488685a7e64da77ae439e818f0514d36edf
|
|
4
|
+
data.tar.gz: dcc0e4973ef2df48e0757c7aa5b283bae9a9f67806c3791f0a09145b67bbde3a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f2a3490f5bd62a47a98774019dc7725701480bedf937ed5fb18f216cb64b8f52efcc1fbaf97dc894e5e9a051ff9a9bc882cfda04555d67ed561bdb0f8a7027e7
|
|
7
|
+
data.tar.gz: 0a8c4db162262c7f61be45d08987e41d71337c15dfd61d4fa2c90245028c3032ad9ea486ffeae2a8426bd32839aa9a274f440b49e42d6614c783611058d0a792
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Pure Ruby MMDB reader - reads MaxMind's native binary format with zero external dependencies
|
|
15
|
+
- IPv4 and IPv6 geolocation lookup via `IpGeoLookup.lookup(ip_string)`
|
|
16
|
+
- `Result` with 10 fields: `country_code`, `country_name`, `region`, `city`, `continent_code`, `continent_name`, `latitude`, `longitude`, `time_zone`, `postal_code` - plus `raw` for the full MMDB record
|
|
17
|
+
- `Result` supports object-style (`result.city`), hash-style (`result[:city]`), `to_h`, `to_s`, `Comparable`
|
|
18
|
+
- `IpAddress.parse` for single-pass IP validation and integer conversion; rejects leading-zero octets (`"08.8.8.8"` returns nil)
|
|
19
|
+
- `IpGeoLookup.configure` for custom database path and I/O mode
|
|
20
|
+
- `IpGeoLookup.reload!`, `.close`, `.reset!`, `.metadata`
|
|
21
|
+
- Thread safety via `Mutex` for all shared state
|
|
22
|
+
- Two I/O modes: `:file` (default, `pread`-based, fork-friendly) and `:memory` (loads entire DB into a String)
|
|
23
|
+
- Support for MMDB record sizes 24, 28, and 32
|
|
24
|
+
- IPv4 lookups in IPv6 databases (automatic 96-bit prefix walk)
|
|
25
|
+
- Use-after-close protection (`ClosedError`)
|
|
26
|
+
- GeoLite2-City.mmdb ships with the gem - no setup, no API keys, no downloads
|
|
27
|
+
- CI via GitHub Actions: test matrix (Ruby 2.6, 3.0, 4.0), StandardRB linting, automated gem publishing
|
|
28
|
+
- Dependabot for actions and bundler dependency updates
|
|
29
|
+
|
|
30
|
+
[Unreleased]: https://github.com/msuliq/ip_geo_lookup/compare/v0.1.0...HEAD
|
|
31
|
+
[0.1.0]: https://github.com/msuliq/ip_geo_lookup/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Suleyman Musayev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# IpGeoLookup
|
|
2
|
+
|
|
3
|
+
A lightweight, **zero-dependency** Ruby gem for resolving IPv4 and IPv6 addresses to geographic locations. Ships with an embedded [MaxMind GeoLite2](https://dev.maxmind.com/geoip/geolite2-free-geolite2-databases) database and a pure Ruby MMDB reader - no external gems, no API keys, no setup.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Zero dependencies** - pure Ruby MMDB reader, no native extensions, no external gems
|
|
8
|
+
- **IPv4 and IPv6** - resolves to country, region, city, coordinates, timezone, and more
|
|
9
|
+
- **Batteries included** - GeoLite2 City database ships with the gem
|
|
10
|
+
- **Fast** - binary trie traversal over MaxMind's MMDB format
|
|
11
|
+
- **Secure** - no `Marshal.load`, rejects ambiguous IP octets with leading zeros
|
|
12
|
+
- **Thread-safe** - safe for Puma, Sidekiq, and other multi-threaded servers
|
|
13
|
+
- **Fork-friendly** - `:file` mode uses `pread` for shared OS page cache across workers
|
|
14
|
+
- **Configurable** - use the embedded database or point to your own `.mmdb` file
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "ip_geo_lookup"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bundle install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That's it - the database is included.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Basic lookup
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require "ip_geo_lookup"
|
|
34
|
+
|
|
35
|
+
result = IpGeoLookup.lookup("8.8.8.8")
|
|
36
|
+
result = IpGeoLookup.lookup("2001:4860:4860::8888")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Accessing results
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
result = IpGeoLookup.lookup("8.8.8.8")
|
|
43
|
+
|
|
44
|
+
# Core fields
|
|
45
|
+
result.country_code # => "US"
|
|
46
|
+
result.country_name # => "United States"
|
|
47
|
+
result.region # => "California"
|
|
48
|
+
result.city # => "Mountain View"
|
|
49
|
+
|
|
50
|
+
# Extended fields
|
|
51
|
+
result.continent_code # => "NA"
|
|
52
|
+
result.continent_name # => "North America"
|
|
53
|
+
result.latitude # => 37.386
|
|
54
|
+
result.longitude # => -122.0838
|
|
55
|
+
result.time_zone # => "America/Los_Angeles"
|
|
56
|
+
result.postal_code # => "94035"
|
|
57
|
+
|
|
58
|
+
# Hash-style access
|
|
59
|
+
result[:country_code] # => "US"
|
|
60
|
+
|
|
61
|
+
# Full MMDB record
|
|
62
|
+
result.raw # => {"country" => {"iso_code" => "US", ...}, ...}
|
|
63
|
+
|
|
64
|
+
# Conversions
|
|
65
|
+
result.to_h # => {country_code: "US", country_name: "United States", ...}
|
|
66
|
+
result.to_s # => "Mountain View, California, United States (US)"
|
|
67
|
+
|
|
68
|
+
# Sorting
|
|
69
|
+
results = ips.map { |ip| IpGeoLookup.lookup(ip) }.compact.sort
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Handling unknown IPs
|
|
73
|
+
|
|
74
|
+
Returns `nil` when the IP address is not found or is invalid:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
IpGeoLookup.lookup("192.168.1.1") # => nil (private range)
|
|
78
|
+
IpGeoLookup.lookup("not_an_ip") # => nil (invalid format)
|
|
79
|
+
IpGeoLookup.lookup("08.8.8.8") # => nil (leading zeros rejected)
|
|
80
|
+
IpGeoLookup.lookup(nil) # => nil
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Configuration
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
IpGeoLookup.configure do |config|
|
|
87
|
+
config.database_path = "/path/to/GeoLite2-City.mmdb"
|
|
88
|
+
config.mode = :memory # optional: load entire DB into memory for single-process apps
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Lifecycle
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
IpGeoLookup.reload! # re-reads from disk, respects configured path
|
|
96
|
+
IpGeoLookup.close # releases file handle / memory
|
|
97
|
+
IpGeoLookup.metadata # => {"database_type" => "GeoLite2-City", ...}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## How It Works
|
|
101
|
+
|
|
102
|
+
The gem includes a pure Ruby reader for MaxMind's [MMDB binary format](https://maxmind.github.io/MaxMind-DB/). On lookup:
|
|
103
|
+
|
|
104
|
+
1. The IP string is validated and converted to an integer in a single pass
|
|
105
|
+
2. The MMDB binary trie is walked (32 bits for IPv4, 128 for IPv6)
|
|
106
|
+
3. The matching record is decoded from the data section
|
|
107
|
+
4. A `Result` object is returned (or `nil` if no match)
|
|
108
|
+
|
|
109
|
+
Two I/O modes are available:
|
|
110
|
+
|
|
111
|
+
- **`:file`** (default) - keeps the file open and uses `IO#pread` for reads; the OS page cache shares data across forked workers automatically
|
|
112
|
+
- **`:memory`** - reads the entire file into a Ruby String for fastest lookups in single-process environments
|
|
113
|
+
|
|
114
|
+
## Compatibility
|
|
115
|
+
|
|
116
|
+
- Ruby >= 2.6.0
|
|
117
|
+
- Zero external dependencies
|
|
118
|
+
- Thread-safe
|
|
119
|
+
- Linux, macOS, Windows
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
bundle install
|
|
125
|
+
bundle exec rake spec
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Test fixtures are generated automatically during the test run - no external database file needed.
|
|
129
|
+
|
|
130
|
+
### Updating the MaxMind database
|
|
131
|
+
|
|
132
|
+
The embedded GeoLite2 City database lives in `data/GeoLite2-City.mmdb` (gitignored due to size). To update it:
|
|
133
|
+
|
|
134
|
+
1. Create a free account at [MaxMind](https://www.maxmind.com/en/geolite2/signup)
|
|
135
|
+
2. Download the GeoLite2 City MMDB file
|
|
136
|
+
3. Copy it into the gem:
|
|
137
|
+
```sh
|
|
138
|
+
cp /path/to/GeoLite2-City.mmdb data/GeoLite2-City.mmdb
|
|
139
|
+
```
|
|
140
|
+
4. Rebuild the gem: `gem build ip_geo_lookup.gemspec`
|
|
141
|
+
|
|
142
|
+
## Contributing
|
|
143
|
+
|
|
144
|
+
1. Fork it
|
|
145
|
+
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
|
146
|
+
3. Commit your changes
|
|
147
|
+
4. Push to the branch
|
|
148
|
+
5. Create a Pull Request
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
Available as open source under the [MIT License](LICENSE).
|
|
153
|
+
|
|
154
|
+
## Attribution
|
|
155
|
+
|
|
156
|
+
This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IpGeoLookup
|
|
4
|
+
module IpAddress
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Returns [integer, version] or nil. Rejects leading-zero octets.
|
|
8
|
+
def parse(ip_string)
|
|
9
|
+
return nil unless ip_string.is_a?(String)
|
|
10
|
+
|
|
11
|
+
addr = ip_string.strip
|
|
12
|
+
if addr.include?(":")
|
|
13
|
+
int = to_i_v6(addr)
|
|
14
|
+
int ? [int, 6] : nil
|
|
15
|
+
elsif addr.include?(".")
|
|
16
|
+
int = to_i(addr)
|
|
17
|
+
int ? [int, 4] : nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# IPv4 string to 32-bit integer. Rejects leading-zero octets.
|
|
22
|
+
def to_i(ip_string)
|
|
23
|
+
return nil unless ip_string.is_a?(String)
|
|
24
|
+
|
|
25
|
+
octets = ip_string.strip.split(".")
|
|
26
|
+
return nil unless octets.length == 4
|
|
27
|
+
|
|
28
|
+
result = 0
|
|
29
|
+
octets.each do |octet|
|
|
30
|
+
return nil unless octet.match?(/\A(0|[1-9]\d{0,2})\z/)
|
|
31
|
+
value = octet.to_i
|
|
32
|
+
return nil if value > 255
|
|
33
|
+
result = (result << 8) | value
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# IPv6 string to 128-bit integer. Supports :: shorthand.
|
|
39
|
+
def to_i_v6(ip_string)
|
|
40
|
+
return nil unless ip_string.is_a?(String)
|
|
41
|
+
|
|
42
|
+
addr = ip_string.strip.downcase
|
|
43
|
+
return nil if addr.empty? || addr.include?("%")
|
|
44
|
+
|
|
45
|
+
if addr.include?("::")
|
|
46
|
+
return nil if addr.scan("::").length > 1
|
|
47
|
+
|
|
48
|
+
left, right = addr.split("::", -1)
|
|
49
|
+
left_groups = left.empty? ? [] : left.split(":")
|
|
50
|
+
right_groups = right.empty? ? [] : right.split(":")
|
|
51
|
+
fill_count = 8 - left_groups.length - right_groups.length
|
|
52
|
+
return nil if fill_count < 0
|
|
53
|
+
|
|
54
|
+
groups = left_groups + Array.new(fill_count, "0") + right_groups
|
|
55
|
+
else
|
|
56
|
+
groups = addr.split(":")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return nil unless groups.length == 8
|
|
60
|
+
|
|
61
|
+
result = 0
|
|
62
|
+
groups.each do |group|
|
|
63
|
+
return nil unless group.match?(/\A[0-9a-f]{1,4}\z/)
|
|
64
|
+
result = (result << 16) | group.to_i(16)
|
|
65
|
+
end
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IpGeoLookup
|
|
4
|
+
# Pure Ruby reader for MaxMind's MMDB binary format.
|
|
5
|
+
class MMDB
|
|
6
|
+
MODE_MEMORY = :memory
|
|
7
|
+
MODE_FILE = :file
|
|
8
|
+
|
|
9
|
+
METADATA_MARKER = "\xAB\xCD\xEFMaxMind.com".b.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :metadata
|
|
12
|
+
|
|
13
|
+
def initialize(path, mode: MODE_MEMORY)
|
|
14
|
+
raise DatabaseNotFoundError, "Database file not found: #{path}" unless File.exist?(path)
|
|
15
|
+
|
|
16
|
+
@closed = false
|
|
17
|
+
|
|
18
|
+
case mode
|
|
19
|
+
when MODE_MEMORY
|
|
20
|
+
@data = File.binread(path)
|
|
21
|
+
@use_file = false
|
|
22
|
+
when MODE_FILE
|
|
23
|
+
@io = File.open(path, "rb")
|
|
24
|
+
@file_size = @io.size
|
|
25
|
+
@use_file = true
|
|
26
|
+
@has_pread = @io.respond_to?(:pread)
|
|
27
|
+
@file_mutex = Mutex.new unless @has_pread
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, "Unknown mode: #{mode}. Use :memory or :file"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
load_metadata
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the MMDB record Hash for the given IP integer, or nil.
|
|
36
|
+
def find(ip_int, bit_count)
|
|
37
|
+
raise ClosedError, "Reader is closed" if @closed
|
|
38
|
+
|
|
39
|
+
if bit_count == 32 && @ip_version == 6
|
|
40
|
+
start = ipv4_start_node
|
|
41
|
+
return nil if start >= @node_count
|
|
42
|
+
search_tree(ip_int, 32, start)
|
|
43
|
+
elsif bit_count == 128 && @ip_version == 4
|
|
44
|
+
nil
|
|
45
|
+
else
|
|
46
|
+
search_tree(ip_int, bit_count, 0)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def close
|
|
51
|
+
@closed = true
|
|
52
|
+
if @use_file
|
|
53
|
+
@io&.close
|
|
54
|
+
@io = nil
|
|
55
|
+
else
|
|
56
|
+
@data = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def read_bytes(offset, length)
|
|
63
|
+
if @use_file
|
|
64
|
+
if @has_pread
|
|
65
|
+
@io.pread(length, offset)
|
|
66
|
+
else
|
|
67
|
+
@file_mutex.synchronize {
|
|
68
|
+
@io.seek(offset)
|
|
69
|
+
@io.read(length)
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
@data.byteslice(offset, length)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- Metadata ---
|
|
78
|
+
|
|
79
|
+
def load_metadata
|
|
80
|
+
size = @use_file ? @file_size : @data.bytesize
|
|
81
|
+
search_size = [size, 131_072].min
|
|
82
|
+
chunk = read_bytes(size - search_size, search_size)
|
|
83
|
+
pos = chunk.rindex(METADATA_MARKER)
|
|
84
|
+
raise DatabaseFormatError, "MMDB metadata marker not found" unless pos
|
|
85
|
+
|
|
86
|
+
meta_offset = (size - search_size) + pos + METADATA_MARKER.bytesize
|
|
87
|
+
@metadata, _ = decode(meta_offset)
|
|
88
|
+
raise DatabaseFormatError, "Invalid metadata" unless @metadata.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
@node_count = @metadata["node_count"] || raise(DatabaseFormatError, "Missing node_count")
|
|
91
|
+
@record_size = @metadata["record_size"] || raise(DatabaseFormatError, "Missing record_size")
|
|
92
|
+
@ip_version = @metadata["ip_version"] || raise(DatabaseFormatError, "Missing ip_version")
|
|
93
|
+
|
|
94
|
+
unless [24, 28, 32].include?(@record_size)
|
|
95
|
+
raise DatabaseFormatError, "Unsupported record size: #{@record_size}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@node_byte_size = @record_size / 4
|
|
99
|
+
@search_tree_size = @node_count * @node_byte_size
|
|
100
|
+
@data_section_offset = @search_tree_size + 16
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- Search tree ---
|
|
104
|
+
|
|
105
|
+
def ipv4_start_node
|
|
106
|
+
@ipv4_start_node ||= begin
|
|
107
|
+
node = 0
|
|
108
|
+
96.times do
|
|
109
|
+
break if node >= @node_count
|
|
110
|
+
node, _ = read_node(node)
|
|
111
|
+
end
|
|
112
|
+
node
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def search_tree(ip_int, bit_count, start_node)
|
|
117
|
+
node = start_node
|
|
118
|
+
|
|
119
|
+
bit_count.times do |i|
|
|
120
|
+
left, right = read_node(node)
|
|
121
|
+
record = (((ip_int >> (bit_count - 1 - i)) & 1) == 0) ? left : right
|
|
122
|
+
|
|
123
|
+
if record < @node_count
|
|
124
|
+
node = record
|
|
125
|
+
elsif record == @node_count
|
|
126
|
+
return nil
|
|
127
|
+
else
|
|
128
|
+
value, _ = decode(@search_tree_size + record - @node_count)
|
|
129
|
+
return value
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Reads one node in a single I/O call, returns [left, right].
|
|
137
|
+
def read_node(node_number)
|
|
138
|
+
buf = read_bytes(node_number * @node_byte_size, @node_byte_size)
|
|
139
|
+
|
|
140
|
+
case @record_size
|
|
141
|
+
when 24
|
|
142
|
+
left = (buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)
|
|
143
|
+
right = (buf.getbyte(3) << 16) | (buf.getbyte(4) << 8) | buf.getbyte(5)
|
|
144
|
+
when 28
|
|
145
|
+
mid = buf.getbyte(3)
|
|
146
|
+
left = ((mid >> 4) << 24) |
|
|
147
|
+
(buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)
|
|
148
|
+
right = ((mid & 0x0F) << 24) |
|
|
149
|
+
(buf.getbyte(4) << 16) | (buf.getbyte(5) << 8) | buf.getbyte(6)
|
|
150
|
+
when 32
|
|
151
|
+
left, right = buf.unpack("NN")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
[left, right]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# --- Data decoder ---
|
|
158
|
+
|
|
159
|
+
def decode(offset)
|
|
160
|
+
ctrl = read_bytes(offset, 1).getbyte(0)
|
|
161
|
+
offset += 1
|
|
162
|
+
|
|
163
|
+
type = (ctrl >> 5) & 7
|
|
164
|
+
if type == 0
|
|
165
|
+
type = read_bytes(offset, 1).getbyte(0) + 7
|
|
166
|
+
offset += 1
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
return decode_pointer(ctrl, offset) if type == 1
|
|
170
|
+
|
|
171
|
+
size, offset = read_payload_size(ctrl, offset)
|
|
172
|
+
|
|
173
|
+
case type
|
|
174
|
+
when 2 then [read_bytes(offset, size).force_encoding("UTF-8"), offset + size]
|
|
175
|
+
when 3 then [read_bytes(offset, 8).unpack1("G"), offset + 8]
|
|
176
|
+
when 4 then [read_bytes(offset, size), offset + size]
|
|
177
|
+
when 5, 6, 9, 10 then decode_uint(size, offset)
|
|
178
|
+
when 7 then decode_map(size, offset)
|
|
179
|
+
when 8 then decode_int32(size, offset)
|
|
180
|
+
when 11 then decode_array(size, offset)
|
|
181
|
+
when 14 then [size != 0, offset]
|
|
182
|
+
when 15 then [read_bytes(offset, 4).unpack1("g"), offset + 4]
|
|
183
|
+
else raise DatabaseFormatError, "Unknown MMDB data type: #{type}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def read_payload_size(ctrl, offset)
|
|
188
|
+
size = ctrl & 0x1F
|
|
189
|
+
if size < 29
|
|
190
|
+
[size, offset]
|
|
191
|
+
elsif size == 29
|
|
192
|
+
[29 + read_bytes(offset, 1).getbyte(0), offset + 1]
|
|
193
|
+
elsif size == 30
|
|
194
|
+
buf = read_bytes(offset, 2)
|
|
195
|
+
[285 + ((buf.getbyte(0) << 8) | buf.getbyte(1)), offset + 2]
|
|
196
|
+
else
|
|
197
|
+
buf = read_bytes(offset, 3)
|
|
198
|
+
[65_821 + ((buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)), offset + 3]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def decode_pointer(ctrl, offset)
|
|
203
|
+
ptr_size = (ctrl >> 3) & 3
|
|
204
|
+
|
|
205
|
+
case ptr_size
|
|
206
|
+
when 0
|
|
207
|
+
ptr = ((ctrl & 7) << 8) | read_bytes(offset, 1).getbyte(0)
|
|
208
|
+
offset += 1
|
|
209
|
+
when 1
|
|
210
|
+
buf = read_bytes(offset, 2)
|
|
211
|
+
ptr = (((ctrl & 7) << 16) | (buf.getbyte(0) << 8) | buf.getbyte(1)) + 2048
|
|
212
|
+
offset += 2
|
|
213
|
+
when 2
|
|
214
|
+
buf = read_bytes(offset, 3)
|
|
215
|
+
ptr = (((ctrl & 7) << 24) | (buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)) + 526_336
|
|
216
|
+
offset += 3
|
|
217
|
+
when 3
|
|
218
|
+
buf = read_bytes(offset, 4)
|
|
219
|
+
ptr = (buf.getbyte(0) << 24) | (buf.getbyte(1) << 16) | (buf.getbyte(2) << 8) | buf.getbyte(3)
|
|
220
|
+
offset += 4
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
value, _ = decode(@data_section_offset + ptr)
|
|
224
|
+
[value, offset]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def decode_uint(size, offset)
|
|
228
|
+
return [0, offset] if size == 0
|
|
229
|
+
buf = read_bytes(offset, size)
|
|
230
|
+
val = 0
|
|
231
|
+
size.times { |i| val = (val << 8) | buf.getbyte(i) }
|
|
232
|
+
[val, offset + size]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def decode_map(size, offset)
|
|
236
|
+
map = {}
|
|
237
|
+
size.times do
|
|
238
|
+
key, offset = decode(offset)
|
|
239
|
+
val, offset = decode(offset)
|
|
240
|
+
map[key] = val
|
|
241
|
+
end
|
|
242
|
+
[map, offset]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def decode_array(size, offset)
|
|
246
|
+
arr = []
|
|
247
|
+
size.times do
|
|
248
|
+
val, offset = decode(offset)
|
|
249
|
+
arr << val
|
|
250
|
+
end
|
|
251
|
+
[arr, offset]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def decode_int32(size, offset)
|
|
255
|
+
val, offset = decode_uint(size, offset)
|
|
256
|
+
val -= (1 << 32) if val >= (1 << 31)
|
|
257
|
+
[val, offset]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
class DatabaseNotFoundError < StandardError; end
|
|
262
|
+
class DatabaseFormatError < StandardError; end
|
|
263
|
+
class ClosedError < StandardError; end
|
|
264
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IpGeoLookup
|
|
4
|
+
class Result
|
|
5
|
+
include Comparable
|
|
6
|
+
|
|
7
|
+
FIELDS = [
|
|
8
|
+
:country_code, :country_name, :region, :city,
|
|
9
|
+
:continent_code, :continent_name,
|
|
10
|
+
:latitude, :longitude, :time_zone, :postal_code
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :country_code, :country_name, :region, :city,
|
|
14
|
+
:continent_code, :continent_name,
|
|
15
|
+
:latitude, :longitude, :time_zone, :postal_code,
|
|
16
|
+
:raw
|
|
17
|
+
|
|
18
|
+
def initialize(country_code:, country_name:, region:, city:,
|
|
19
|
+
continent_code: nil, continent_name: nil,
|
|
20
|
+
latitude: nil, longitude: nil,
|
|
21
|
+
time_zone: nil, postal_code: nil,
|
|
22
|
+
raw: nil)
|
|
23
|
+
@country_code = country_code
|
|
24
|
+
@country_name = country_name
|
|
25
|
+
@region = region
|
|
26
|
+
@city = city
|
|
27
|
+
@continent_code = continent_code
|
|
28
|
+
@continent_name = continent_name
|
|
29
|
+
@latitude = latitude
|
|
30
|
+
@longitude = longitude
|
|
31
|
+
@time_zone = time_zone
|
|
32
|
+
@postal_code = postal_code
|
|
33
|
+
@raw = raw
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def [](key)
|
|
37
|
+
case key.to_sym
|
|
38
|
+
when :country_code then @country_code
|
|
39
|
+
when :country_name then @country_name
|
|
40
|
+
when :region then @region
|
|
41
|
+
when :city then @city
|
|
42
|
+
when :continent_code then @continent_code
|
|
43
|
+
when :continent_name then @continent_name
|
|
44
|
+
when :latitude then @latitude
|
|
45
|
+
when :longitude then @longitude
|
|
46
|
+
when :time_zone then @time_zone
|
|
47
|
+
when :postal_code then @postal_code
|
|
48
|
+
when :raw then @raw
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
{
|
|
54
|
+
country_code: @country_code,
|
|
55
|
+
country_name: @country_name,
|
|
56
|
+
region: @region,
|
|
57
|
+
city: @city,
|
|
58
|
+
continent_code: @continent_code,
|
|
59
|
+
continent_name: @continent_name,
|
|
60
|
+
latitude: @latitude,
|
|
61
|
+
longitude: @longitude,
|
|
62
|
+
time_zone: @time_zone,
|
|
63
|
+
postal_code: @postal_code
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_a
|
|
68
|
+
[@country_code, @country_name, @region, @city,
|
|
69
|
+
@continent_code, @continent_name,
|
|
70
|
+
@latitude, @longitude, @time_zone, @postal_code]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def members
|
|
74
|
+
FIELDS.dup
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_s
|
|
78
|
+
location = [@city, @region, @country_name].reject { |s| s.nil? || s.empty? }.join(", ")
|
|
79
|
+
if @country_code && !@country_code.empty?
|
|
80
|
+
location.empty? ? @country_code : "#{location} (#{@country_code})"
|
|
81
|
+
else
|
|
82
|
+
location
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inspect
|
|
87
|
+
"#<IpGeoLookup::Result #{self}>"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def <=>(other)
|
|
91
|
+
return nil unless other.is_a?(Result)
|
|
92
|
+
[country_code.to_s, region.to_s, city.to_s] <=> [other.country_code.to_s, other.region.to_s, other.city.to_s]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Equality based on to_h; raw is intentionally excluded.
|
|
96
|
+
def ==(other)
|
|
97
|
+
return false unless other.is_a?(Result)
|
|
98
|
+
to_h == other.to_h
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
alias_method :eql?, :==
|
|
102
|
+
|
|
103
|
+
def hash
|
|
104
|
+
to_h.hash
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ip_geo_lookup/version"
|
|
4
|
+
require_relative "ip_geo_lookup/result"
|
|
5
|
+
require_relative "ip_geo_lookup/ip_address"
|
|
6
|
+
require_relative "ip_geo_lookup/mmdb"
|
|
7
|
+
|
|
8
|
+
module IpGeoLookup
|
|
9
|
+
DEFAULT_DB_PATH = File.expand_path("../../data/GeoLite2-City.mmdb", __FILE__).freeze
|
|
10
|
+
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Returns a Result for the given IP string, or nil.
|
|
15
|
+
def lookup(ip_address)
|
|
16
|
+
return nil unless ip_address.is_a?(String)
|
|
17
|
+
|
|
18
|
+
parsed = IpAddress.parse(ip_address)
|
|
19
|
+
return nil unless parsed
|
|
20
|
+
|
|
21
|
+
ip_int, version = parsed
|
|
22
|
+
record = reader.find(ip_int, (version == 6) ? 128 : 32)
|
|
23
|
+
return nil unless record
|
|
24
|
+
|
|
25
|
+
build_result(record)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def metadata
|
|
29
|
+
reader.metadata
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reloads from disk; respects configured path.
|
|
33
|
+
def reload!
|
|
34
|
+
@mutex.synchronize { @reader = nil }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def close
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@reader&.close
|
|
40
|
+
@reader = nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def configure
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
yield configuration
|
|
47
|
+
@reader = MMDB.new(configuration.database_path, mode: configuration.mode)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def configuration
|
|
52
|
+
@configuration ||= Configuration.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reset!
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@reader = nil
|
|
58
|
+
@configuration = nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def reader
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@reader ||= MMDB.new(configuration.database_path, mode: configuration.mode)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_result(record)
|
|
71
|
+
Result.new(
|
|
72
|
+
country_code: deep_fetch(record, "country", "iso_code"),
|
|
73
|
+
country_name: deep_fetch(record, "country", "names", "en"),
|
|
74
|
+
region: deep_fetch(record, "subdivisions", 0, "names", "en"),
|
|
75
|
+
city: deep_fetch(record, "city", "names", "en"),
|
|
76
|
+
continent_code: deep_fetch(record, "continent", "code"),
|
|
77
|
+
continent_name: deep_fetch(record, "continent", "names", "en"),
|
|
78
|
+
latitude: deep_fetch(record, "location", "latitude"),
|
|
79
|
+
longitude: deep_fetch(record, "location", "longitude"),
|
|
80
|
+
time_zone: deep_fetch(record, "location", "time_zone"),
|
|
81
|
+
postal_code: deep_fetch(record, "postal", "code"),
|
|
82
|
+
raw: record
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def deep_fetch(obj, *keys)
|
|
87
|
+
keys.each do |key|
|
|
88
|
+
case obj
|
|
89
|
+
when Hash then obj = obj[key]
|
|
90
|
+
when Array then obj = obj[key]
|
|
91
|
+
else return nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
obj
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class Configuration
|
|
99
|
+
attr_writer :database_path, :mode
|
|
100
|
+
|
|
101
|
+
def database_path
|
|
102
|
+
@database_path || DEFAULT_DB_PATH
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def mode
|
|
106
|
+
@mode || MMDB::MODE_FILE
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ip_geo_lookup
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Suleyman Musayev
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A lightweight, zero-dependency Ruby gem for resolving IPv4 and IPv6 addresses
|
|
14
|
+
to country, region, city, coordinates, and more. Ships with an embedded MaxMind
|
|
15
|
+
GeoLite2 MMDB database and a pure Ruby MMDB reader - no external gems, no API keys,
|
|
16
|
+
no setup.
|
|
17
|
+
email:
|
|
18
|
+
- slmusayev@gmail.com
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- LICENSE
|
|
25
|
+
- README.md
|
|
26
|
+
- lib/ip_geo_lookup.rb
|
|
27
|
+
- lib/ip_geo_lookup/ip_address.rb
|
|
28
|
+
- lib/ip_geo_lookup/mmdb.rb
|
|
29
|
+
- lib/ip_geo_lookup/result.rb
|
|
30
|
+
- lib/ip_geo_lookup/version.rb
|
|
31
|
+
homepage: https://github.com/msuliq/ip_geo_lookup
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
rubygems_mfa_required: 'true'
|
|
36
|
+
homepage_uri: https://github.com/msuliq/ip_geo_lookup
|
|
37
|
+
source_code_uri: https://github.com/msuliq/ip_geo_lookup
|
|
38
|
+
changelog_uri: https://github.com/msuliq/ip_geo_lookup/blob/main/CHANGELOG.md
|
|
39
|
+
bug_tracker_uri: https://github.com/msuliq/ip_geo_lookup/issues
|
|
40
|
+
post_install_message:
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 2.6.0
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 3.2.33
|
|
56
|
+
signing_key:
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Zero-dependency IPv4/IPv6 geolocation lookup with embedded MaxMind database
|
|
59
|
+
test_files: []
|