trackdown 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +478 -21
- data/lib/generators/trackdown/install_generator.rb +14 -5
- data/lib/generators/trackdown/templates/trackdown.rb +87 -4
- data/lib/trackdown/configuration.rb +112 -6
- data/lib/trackdown/database_fingerprint.rb +102 -0
- data/lib/trackdown/database_updater.rb +89 -28
- data/lib/trackdown/ip_locator.rb +4 -1
- data/lib/trackdown/location_result.rb +223 -18
- data/lib/trackdown/providers/auto_provider.rb +251 -20
- data/lib/trackdown/providers/base_provider.rb +71 -5
- data/lib/trackdown/providers/cloudflare_provider.rb +57 -20
- data/lib/trackdown/providers/cloudfront_provider.rb +197 -0
- data/lib/trackdown/providers/maxmind_provider.rb +113 -16
- data/lib/trackdown/version.rb +1 -1
- data/lib/trackdown.rb +3 -1
- metadata +12 -16
- data/.simplecov +0 -25
- data/AGENTS.md +0 -5
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -12
- data/sig/trackdown.rbs +0 -4
|
@@ -2,9 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require 'countries'
|
|
4
4
|
|
|
5
|
+
require_relative '../location_result'
|
|
6
|
+
|
|
5
7
|
module Trackdown
|
|
6
8
|
module Providers
|
|
7
9
|
class BaseProvider
|
|
10
|
+
# What a CDN may legitimately send as a coordinate: a plain decimal number,
|
|
11
|
+
# and nothing else. Ruling the rest out here rather than after converting
|
|
12
|
+
# rejects hexadecimal ("0x10" is a perfectly good latitude of 16.0 to
|
|
13
|
+
# Kernel#Float), underscored digits, and the "NaN"/"Infinity" literals.
|
|
14
|
+
#
|
|
15
|
+
# The digit and exponent limits are not arbitrary: no WGS-84 coordinate
|
|
16
|
+
# exceeds 180, so three integer digits is already generous, and staying this
|
|
17
|
+
# side of Float's range means converting a forged header can never overflow.
|
|
18
|
+
# Ruby warns about an overflowing conversion in verbose mode, and untrusted
|
|
19
|
+
# input must not be able to make a library talk into someone's logs.
|
|
20
|
+
# https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
|
|
21
|
+
DECIMAL_COORDINATE_PATTERN = /\A[+-]?\d{1,3}(?:\.\d+)?(?:[eE][+-]?\d{1,2})?\z/
|
|
22
|
+
private_constant :DECIMAL_COORDINATE_PATTERN
|
|
8
23
|
# Returns true if this provider can handle the given request/context
|
|
9
24
|
def self.available?(request: nil)
|
|
10
25
|
raise NotImplementedError, "#{self} must implement .available?"
|
|
@@ -18,21 +33,72 @@ module Trackdown
|
|
|
18
33
|
raise NotImplementedError, "#{self} must implement .locate"
|
|
19
34
|
end
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
# How a result names this provider: the same symbol you'd set as
|
|
37
|
+
# `config.provider`, so `result.provider_name == :cloudflare` lines up with
|
|
38
|
+
# `config.provider = :cloudflare`.
|
|
39
|
+
def self.provider_name
|
|
40
|
+
raise NotImplementedError, "#{self} must implement .provider_name"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Where this provider's answers physically come from, e.g.
|
|
44
|
+
# :cloudflare_request_headers or :maxmind_local_database.
|
|
45
|
+
def self.provider_source
|
|
46
|
+
raise NotImplementedError, "#{self} must implement .provider_source"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The provenance a request-backed provider stamps on every result.
|
|
50
|
+
#
|
|
51
|
+
# The trust state is :unverified unless the host's own verifier vouches for
|
|
52
|
+
# the request. Trackdown never reads trust out of the headers themselves —
|
|
53
|
+
# anyone who can reach an unprotected origin can send those.
|
|
54
|
+
def self.request_provenance(request)
|
|
55
|
+
source_was_verified = Trackdown.configuration.request_came_through_trusted_cdn_path?(
|
|
56
|
+
request,
|
|
57
|
+
provider_name: provider_name
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
provider_name: provider_name,
|
|
62
|
+
provider_source: provider_source,
|
|
63
|
+
source_trust: source_was_verified ? :host_verified : :unverified
|
|
64
|
+
}
|
|
65
|
+
end
|
|
22
66
|
|
|
23
67
|
# Helper to get emoji flag from country code
|
|
24
68
|
def self.get_emoji_flag(country_code)
|
|
25
|
-
|
|
69
|
+
return LocationResult::UNKNOWN_FLAG unless country_code.is_a?(String)
|
|
70
|
+
return LocationResult::UNKNOWN_FLAG unless /\A[A-Za-z]{2}\z/.match?(country_code)
|
|
71
|
+
|
|
72
|
+
normalized_code = country_code.upcase
|
|
73
|
+
normalized_code.tr('A-Z', "\u{1F1E6}-\u{1F1FF}")
|
|
26
74
|
end
|
|
27
75
|
|
|
28
76
|
# Helper to extract country name from country code using countries gem
|
|
29
77
|
def self.get_country_name(country_code)
|
|
30
|
-
return
|
|
78
|
+
return LocationResult::UNKNOWN unless country_code
|
|
31
79
|
|
|
32
80
|
country = ISO3166::Country.new(country_code)
|
|
33
|
-
country&.iso_short_name || country&.name ||
|
|
81
|
+
country&.iso_short_name || country&.name || LocationResult::UNKNOWN
|
|
34
82
|
rescue StandardError
|
|
35
|
-
|
|
83
|
+
LocationResult::UNKNOWN
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Parse an untrusted coordinate: a plain decimal within the given WGS-84
|
|
87
|
+
# bounds, or nothing. The range check also settles NaN and Infinity, since
|
|
88
|
+
# a Range covers neither.
|
|
89
|
+
# https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
|
|
90
|
+
def self.parse_coordinate(value, range:)
|
|
91
|
+
return nil unless value.is_a?(String)
|
|
92
|
+
|
|
93
|
+
decimal = value.strip
|
|
94
|
+
return nil unless DECIMAL_COORDINATE_PATTERN.match?(decimal)
|
|
95
|
+
|
|
96
|
+
coordinate = decimal.to_f
|
|
97
|
+
range.cover?(coordinate) ? coordinate : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class << self
|
|
101
|
+
protected :get_emoji_flag, :get_country_name, :parse_coordinate
|
|
36
102
|
end
|
|
37
103
|
end
|
|
38
104
|
end
|
|
@@ -10,6 +10,10 @@ module Trackdown
|
|
|
10
10
|
#
|
|
11
11
|
# Cloudflare must have "IP Geolocation" or "Add visitor location headers" enabled
|
|
12
12
|
# in the dashboard under Network settings or via Managed Transforms
|
|
13
|
+
# Exact header contract:
|
|
14
|
+
# https://developers.cloudflare.com/fundamentals/reference/http-headers/
|
|
15
|
+
# Exact origin-protection guidance:
|
|
16
|
+
# https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/
|
|
13
17
|
class CloudflareProvider < BaseProvider
|
|
14
18
|
COUNTRY_HEADER = 'HTTP_CF_IPCOUNTRY'
|
|
15
19
|
CITY_HEADER = 'HTTP_CF_IPCITY'
|
|
@@ -22,30 +26,54 @@ module Trackdown
|
|
|
22
26
|
METRO_CODE_HEADER = 'HTTP_CF_METRO_CODE'
|
|
23
27
|
POSTAL_CODE_HEADER = 'HTTP_CF_POSTAL_CODE'
|
|
24
28
|
|
|
25
|
-
#
|
|
29
|
+
# Cloudflare's XX and T1 pseudo-codes do not name countries. Unicode also
|
|
30
|
+
# defines ZZ as unknown/invalid territory, so none is treated as a country.
|
|
31
|
+
# Cloudflare: https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-ipcountry
|
|
32
|
+
# Unicode ZZ semantics:
|
|
33
|
+
# https://www.unicode.org/reports/tr35/tr35-78/tr35.html#unicode_region_subtag_validity
|
|
26
34
|
UNKNOWN_CODE = 'XX'
|
|
35
|
+
UNKNOWN_OR_INVALID_TERRITORY_CODE = 'ZZ'
|
|
27
36
|
TOR_CODE = 'T1'
|
|
37
|
+
UNAVAILABLE_COUNTRY_CODES = [UNKNOWN_CODE, UNKNOWN_OR_INVALID_TERRITORY_CODE].freeze
|
|
38
|
+
COUNTRY_CODE_PATTERN = /\A[A-Za-z]{2}\z/
|
|
39
|
+
LATITUDE_RANGE = (-90.0..90.0)
|
|
40
|
+
LONGITUDE_RANGE = (-180.0..180.0)
|
|
41
|
+
|
|
42
|
+
private_constant :UNAVAILABLE_COUNTRY_CODES,
|
|
43
|
+
:COUNTRY_CODE_PATTERN,
|
|
44
|
+
:LATITUDE_RANGE,
|
|
45
|
+
:LONGITUDE_RANGE
|
|
28
46
|
|
|
29
47
|
class << self
|
|
48
|
+
def provider_name
|
|
49
|
+
:cloudflare
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def provider_source
|
|
53
|
+
:cloudflare_request_headers
|
|
54
|
+
end
|
|
55
|
+
|
|
30
56
|
# Check if Cloudflare headers are available in the request
|
|
31
57
|
def available?(request: nil)
|
|
32
58
|
return false unless request
|
|
33
59
|
|
|
34
|
-
|
|
35
|
-
!country_code.nil? && !country_code.empty? && country_code != UNKNOWN_CODE
|
|
60
|
+
!extract_country_code(request).nil?
|
|
36
61
|
end
|
|
37
62
|
|
|
38
63
|
# Locate IP using Cloudflare headers
|
|
39
64
|
# @param ip [String] The IP address (not used, as Cloudflare already resolved it)
|
|
40
65
|
# @param request [ActionDispatch::Request] Rails request object with Cloudflare headers
|
|
41
66
|
# @return [LocationResult] The location information
|
|
42
|
-
def locate(
|
|
67
|
+
def locate(_ip, request: nil)
|
|
43
68
|
raise Trackdown::Error, "CloudflareProvider requires a request object with Cloudflare headers" unless request
|
|
44
69
|
|
|
70
|
+
provenance = request_provenance(request)
|
|
45
71
|
country_code = extract_country_code(request)
|
|
46
72
|
|
|
47
73
|
# If no valid country code, return unknown
|
|
48
|
-
|
|
74
|
+
if country_code.nil? || country_code == UNKNOWN_CODE
|
|
75
|
+
return LocationResult.unavailable(:provider_returned_unknown_country, **provenance)
|
|
76
|
+
end
|
|
49
77
|
|
|
50
78
|
country_name = get_country_name(country_code)
|
|
51
79
|
city = extract_city(request)
|
|
@@ -57,10 +85,18 @@ module Trackdown
|
|
|
57
85
|
region_code: extract_header(request, REGION_CODE_HEADER),
|
|
58
86
|
continent: extract_header(request, CONTINENT_HEADER),
|
|
59
87
|
timezone: extract_header(request, TIMEZONE_HEADER),
|
|
60
|
-
latitude: parse_coordinate(request.env[LATITUDE_HEADER]),
|
|
61
|
-
longitude: parse_coordinate(request.env[LONGITUDE_HEADER]),
|
|
88
|
+
latitude: parse_coordinate(request.env[LATITUDE_HEADER], range: LATITUDE_RANGE),
|
|
89
|
+
longitude: parse_coordinate(request.env[LONGITUDE_HEADER], range: LONGITUDE_RANGE),
|
|
62
90
|
postal_code: extract_header(request, POSTAL_CODE_HEADER),
|
|
63
|
-
metro_code: extract_header(request, METRO_CODE_HEADER)
|
|
91
|
+
metro_code: extract_header(request, METRO_CODE_HEADER),
|
|
92
|
+
# "T1" says the visitor came through Tor, which is precisely a country
|
|
93
|
+
# Cloudflare could not determine. The code is kept, the claim is not.
|
|
94
|
+
# Only Cloudflare's own two pseudo-codes are treated this way: a code
|
|
95
|
+
# we simply haven't heard of (Kosovo's user-assigned "XK", say) is a
|
|
96
|
+
# real answer, not an unresolved one.
|
|
97
|
+
# https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-ipcountry
|
|
98
|
+
unavailable_reason: (:provider_returned_unknown_country if country_code == TOR_CODE),
|
|
99
|
+
**provenance
|
|
64
100
|
)
|
|
65
101
|
end
|
|
66
102
|
|
|
@@ -68,9 +104,16 @@ module Trackdown
|
|
|
68
104
|
|
|
69
105
|
def extract_country_code(request)
|
|
70
106
|
code = request.env[COUNTRY_HEADER]
|
|
71
|
-
return nil
|
|
107
|
+
return nil unless code.is_a?(String)
|
|
108
|
+
|
|
109
|
+
normalized_code = code.upcase
|
|
110
|
+
return nil if UNAVAILABLE_COUNTRY_CODES.include?(normalized_code)
|
|
111
|
+
return normalized_code if normalized_code == TOR_CODE
|
|
112
|
+
return nil unless COUNTRY_CODE_PATTERN.match?(code)
|
|
72
113
|
|
|
73
|
-
|
|
114
|
+
normalized_code
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
74
117
|
end
|
|
75
118
|
|
|
76
119
|
def extract_city(request)
|
|
@@ -78,25 +121,19 @@ module Trackdown
|
|
|
78
121
|
|
|
79
122
|
# Cloudflare city header might not always be present
|
|
80
123
|
# It requires "Add visitor location headers" Managed Transform
|
|
81
|
-
return 'Unknown'
|
|
124
|
+
return 'Unknown' unless city.is_a?(String)
|
|
125
|
+
return 'Unknown' if city.empty?
|
|
82
126
|
|
|
83
127
|
city
|
|
84
128
|
end
|
|
85
129
|
|
|
86
130
|
def extract_header(request, header)
|
|
87
131
|
value = request.env[header]
|
|
88
|
-
return nil
|
|
132
|
+
return nil unless value.is_a?(String)
|
|
133
|
+
return nil if value.empty?
|
|
89
134
|
|
|
90
135
|
value
|
|
91
136
|
end
|
|
92
|
-
|
|
93
|
-
def parse_coordinate(value)
|
|
94
|
-
return nil if value.nil? || value.empty?
|
|
95
|
-
|
|
96
|
-
Float(value)
|
|
97
|
-
rescue ArgumentError, TypeError
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
100
137
|
end
|
|
101
138
|
end
|
|
102
139
|
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_provider'
|
|
4
|
+
require_relative '../location_result'
|
|
5
|
+
|
|
6
|
+
module Trackdown
|
|
7
|
+
module Providers
|
|
8
|
+
# Provider that uses Amazon CloudFront HTTP headers for IP geolocation.
|
|
9
|
+
# This is the fastest and most lightweight option when your app is behind
|
|
10
|
+
# CloudFront (the AWS CDN) — a direct analog to the Cloudflare provider.
|
|
11
|
+
#
|
|
12
|
+
# CloudFront resolves the viewer's location at the edge and forwards it to the
|
|
13
|
+
# origin as CloudFront-Viewer-* headers. To receive them, attach an origin
|
|
14
|
+
# request policy that forwards the CloudFront geolocation headers.
|
|
15
|
+
#
|
|
16
|
+
# Exact AWS viewer-location header contract (names, availability, encoding):
|
|
17
|
+
# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
|
|
18
|
+
# Exact AWS managed-policy contents:
|
|
19
|
+
# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
|
|
20
|
+
#
|
|
21
|
+
# IMPORTANT: Header presence does not authenticate CloudFront. A custom origin
|
|
22
|
+
# must reject direct traffic before an application can trust these values. AWS's
|
|
23
|
+
# exact origin-restriction guidance is:
|
|
24
|
+
# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html
|
|
25
|
+
#
|
|
26
|
+
# Note: CloudFront does not emit a continent header. We derive #continent from the
|
|
27
|
+
# country code so it matches the 2-letter code (e.g. "NA") the other providers
|
|
28
|
+
# return. The countries gem exposes a continent *name* ("North America") but no
|
|
29
|
+
# code, and its names don't map by initials (Africa/Asia/Antarctica/Australia all
|
|
30
|
+
# start with "A", and it labels Oceania "Australia"), so an explicit table is the
|
|
31
|
+
# only reliable mapping.
|
|
32
|
+
class CloudfrontProvider < BaseProvider
|
|
33
|
+
# ISO3166 continent name (from the countries gem) => 2-letter code used by the
|
|
34
|
+
# Cloudflare and MaxMind providers.
|
|
35
|
+
# countries gem source: https://github.com/countries/countries
|
|
36
|
+
CONTINENT_CODES = {
|
|
37
|
+
'Africa' => 'AF',
|
|
38
|
+
'Antarctica' => 'AN',
|
|
39
|
+
'Asia' => 'AS',
|
|
40
|
+
'Europe' => 'EU',
|
|
41
|
+
'North America' => 'NA',
|
|
42
|
+
'South America' => 'SA',
|
|
43
|
+
'Australia' => 'OC' # the countries gem labels Oceania "Australia"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Rack exposes ordinary HTTP request headers as HTTP_* environment entries:
|
|
47
|
+
# https://github.com/rack/rack/blob/main/SPEC.rdoc#http_-headers
|
|
48
|
+
COUNTRY_HEADER = 'HTTP_CLOUDFRONT_VIEWER_COUNTRY'
|
|
49
|
+
CITY_HEADER = 'HTTP_CLOUDFRONT_VIEWER_CITY'
|
|
50
|
+
# CloudFront exposes both a region code ("CA") and its full name ("California").
|
|
51
|
+
# Match the Cloudflare provider's semantics: #region is the name, #region_code the code.
|
|
52
|
+
REGION_HEADER = 'HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION_NAME'
|
|
53
|
+
REGION_CODE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION'
|
|
54
|
+
LATITUDE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_LATITUDE'
|
|
55
|
+
LONGITUDE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_LONGITUDE'
|
|
56
|
+
TIMEZONE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_TIME_ZONE'
|
|
57
|
+
POSTAL_CODE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_POSTAL_CODE'
|
|
58
|
+
METRO_CODE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_METRO_CODE'
|
|
59
|
+
|
|
60
|
+
COUNTRY_CODE_PATTERN = /\A[A-Za-z]{2}\z/
|
|
61
|
+
INDUSTRY_PRACTICE_COUNTRY_CODES = %w[XK].freeze
|
|
62
|
+
INVALID_PERCENT_ESCAPE_PATTERN = /%(?![0-9A-Fa-f]{2})/
|
|
63
|
+
PERCENT_ESCAPE_PATTERN = /%([0-9A-Fa-f]{2})/
|
|
64
|
+
LATITUDE_RANGE = (-90.0..90.0)
|
|
65
|
+
LONGITUDE_RANGE = (-180.0..180.0)
|
|
66
|
+
|
|
67
|
+
private_constant :COUNTRY_CODE_PATTERN,
|
|
68
|
+
:INDUSTRY_PRACTICE_COUNTRY_CODES,
|
|
69
|
+
:INVALID_PERCENT_ESCAPE_PATTERN,
|
|
70
|
+
:PERCENT_ESCAPE_PATTERN,
|
|
71
|
+
:LATITUDE_RANGE,
|
|
72
|
+
:LONGITUDE_RANGE
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
def provider_name
|
|
76
|
+
:cloudfront
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def provider_source
|
|
80
|
+
:cloudfront_request_headers
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check if CloudFront headers are available in the request
|
|
84
|
+
def available?(request: nil)
|
|
85
|
+
return false unless request
|
|
86
|
+
|
|
87
|
+
!extract_country_code(request).nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Locate IP using CloudFront headers
|
|
91
|
+
# @param ip [String] The IP address (not used, as CloudFront already resolved it)
|
|
92
|
+
# @param request [#env] Rack-compatible request object with CloudFront headers
|
|
93
|
+
# @return [LocationResult] The location information
|
|
94
|
+
def locate(_ip, request: nil)
|
|
95
|
+
raise Trackdown::Error, 'CloudfrontProvider requires a request object with CloudFront headers' unless request
|
|
96
|
+
|
|
97
|
+
provenance = request_provenance(request)
|
|
98
|
+
country_code = extract_country_code(request)
|
|
99
|
+
|
|
100
|
+
# If no valid country code, return unknown
|
|
101
|
+
return LocationResult.unavailable(:provider_returned_unknown_country, **provenance) if country_code.nil?
|
|
102
|
+
|
|
103
|
+
build_location_result(country_code, request, **provenance)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def build_location_result(country_code, request, **provenance)
|
|
109
|
+
LocationResult.new(
|
|
110
|
+
country_code,
|
|
111
|
+
get_country_name(country_code),
|
|
112
|
+
extract_city(request),
|
|
113
|
+
get_emoji_flag(country_code),
|
|
114
|
+
region: extract_header(request, REGION_HEADER),
|
|
115
|
+
region_code: extract_header(request, REGION_CODE_HEADER),
|
|
116
|
+
continent: continent_code(country_code),
|
|
117
|
+
timezone: extract_header(request, TIMEZONE_HEADER),
|
|
118
|
+
latitude: parse_coordinate(request.env[LATITUDE_HEADER], range: LATITUDE_RANGE),
|
|
119
|
+
longitude: parse_coordinate(request.env[LONGITUDE_HEADER], range: LONGITUDE_RANGE),
|
|
120
|
+
postal_code: extract_header(request, POSTAL_CODE_HEADER),
|
|
121
|
+
metro_code: extract_header(request, METRO_CODE_HEADER),
|
|
122
|
+
**provenance
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Derive the 2-letter continent code from the country code via the countries gem.
|
|
127
|
+
# Returns nil for unknown countries or continents outside the table.
|
|
128
|
+
def continent_code(country_code)
|
|
129
|
+
country = ISO3166::Country.new(country_code)
|
|
130
|
+
CONTINENT_CODES[country&.continent]
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_country_code(request)
|
|
136
|
+
# AWS specifies an ISO 3166-1 alpha-2 value and links the authoritative list:
|
|
137
|
+
# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
|
|
138
|
+
code = request.env[COUNTRY_HEADER]
|
|
139
|
+
return nil unless code.is_a?(String)
|
|
140
|
+
|
|
141
|
+
return nil unless COUNTRY_CODE_PATTERN.match?(code)
|
|
142
|
+
|
|
143
|
+
normalized_code = code.upcase
|
|
144
|
+
|
|
145
|
+
# Unicode CLDR gives XK (Kosovo) defined industry-practice semantics,
|
|
146
|
+
# while ZZ explicitly means an unknown or invalid territory. Preserve
|
|
147
|
+
# XK even though the countries gem's ISO catalog does not contain it;
|
|
148
|
+
# continue to reject arbitrary unassigned codes such as ZZ.
|
|
149
|
+
# https://www.unicode.org/reports/tr35/tr35-78/tr35.html#unicode_region_subtag_validity
|
|
150
|
+
return normalized_code if INDUSTRY_PRACTICE_COUNTRY_CODES.include?(normalized_code)
|
|
151
|
+
|
|
152
|
+
country = ISO3166::Country.new(normalized_code)
|
|
153
|
+
return nil unless country&.alpha2 == normalized_code
|
|
154
|
+
|
|
155
|
+
normalized_code
|
|
156
|
+
rescue StandardError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def extract_city(request)
|
|
161
|
+
city = decode_header_value(request.env[CITY_HEADER])
|
|
162
|
+
|
|
163
|
+
# The city header is only present when the origin request policy forwards it.
|
|
164
|
+
return 'Unknown' if city.nil?
|
|
165
|
+
|
|
166
|
+
city
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def extract_header(request, header)
|
|
170
|
+
decode_header_value(request.env[header])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# AWS percent-encodes non-ASCII viewer-location header characters according
|
|
174
|
+
# to RFC 3986. Decode percent octets—not form data—so a literal "+" remains
|
|
175
|
+
# a plus. Reject malformed escapes and invalid UTF-8 instead of exposing
|
|
176
|
+
# ambiguous/binary strings to callers.
|
|
177
|
+
# AWS: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
|
|
178
|
+
# RFC 3986: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
|
|
179
|
+
def decode_header_value(value)
|
|
180
|
+
return nil unless value.is_a?(String)
|
|
181
|
+
return nil if value.empty?
|
|
182
|
+
|
|
183
|
+
encoded_value = value.b
|
|
184
|
+
return nil if INVALID_PERCENT_ESCAPE_PATTERN.match?(encoded_value)
|
|
185
|
+
|
|
186
|
+
decoded_value = encoded_value.gsub(PERCENT_ESCAPE_PATTERN) do
|
|
187
|
+
Regexp.last_match(1).to_i(16).chr
|
|
188
|
+
end
|
|
189
|
+
decoded_value.force_encoding(Encoding::UTF_8)
|
|
190
|
+
return nil unless decoded_value.valid_encoding?
|
|
191
|
+
|
|
192
|
+
decoded_value
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'timeout'
|
|
4
4
|
require_relative 'base_provider'
|
|
5
5
|
require_relative '../location_result'
|
|
6
|
+
require_relative '../database_fingerprint'
|
|
6
7
|
|
|
7
8
|
# Conditionally require MaxMind - this is an optional dependency
|
|
8
9
|
begin
|
|
@@ -20,10 +21,30 @@ module Trackdown
|
|
|
20
21
|
class TimeoutError < Trackdown::Error; end
|
|
21
22
|
class DatabaseError < Trackdown::Error; end
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
DatabaseReader = Struct.new(:reader, :fingerprint, keyword_init: true)
|
|
25
|
+
DatabaseLookup = Struct.new(:record, :fingerprint, keyword_init: true)
|
|
26
|
+
private_constant :DatabaseReader, :DatabaseLookup
|
|
27
|
+
|
|
28
|
+
# MaxMind publishes the accuracy radius as the radius, in kilometres, within
|
|
29
|
+
# which the address is likely to be, at a 67% confidence level:
|
|
30
|
+
# https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy
|
|
31
|
+
ACCURACY_RADIUS_CONFIDENCE_PERCENTAGE = 67
|
|
32
|
+
|
|
33
|
+
@reader_pool = nil
|
|
34
|
+
@pool_mutex = Mutex.new
|
|
35
|
+
@database_fingerprint = nil
|
|
36
|
+
@database_fingerprints = {}
|
|
37
|
+
@fingerprint_mutex = Mutex.new
|
|
25
38
|
|
|
26
39
|
class << self
|
|
40
|
+
def provider_name
|
|
41
|
+
:maxmind
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def provider_source
|
|
45
|
+
:maxmind_local_database
|
|
46
|
+
end
|
|
47
|
+
|
|
27
48
|
# Check if MaxMind database is available
|
|
28
49
|
def available?(request: nil)
|
|
29
50
|
return false unless maxmind_available?
|
|
@@ -32,6 +53,29 @@ module Trackdown
|
|
|
32
53
|
true
|
|
33
54
|
end
|
|
34
55
|
|
|
56
|
+
# The fingerprint used by the most recent successful reader fetch. Results
|
|
57
|
+
# do not read this global diagnostic: each one retains its reader-bound
|
|
58
|
+
# fingerprint, so concurrent generations can never mix provenance.
|
|
59
|
+
def database_fingerprint
|
|
60
|
+
@fingerprint_mutex.synchronize { @database_fingerprint }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Forget the open database. Call this after replacing the .mmdb file so the
|
|
64
|
+
# next lookup opens the new one — Trackdown::DatabaseUpdater already does.
|
|
65
|
+
def reset_database!
|
|
66
|
+
# Let go of the pool rather than shutting it down: a lookup already in
|
|
67
|
+
# flight must not fail because a refresh happened underneath it. Ruby
|
|
68
|
+
# reclaims the old readers once the last lookup lets go of them.
|
|
69
|
+
@pool_mutex.synchronize do
|
|
70
|
+
@reader_pool = nil
|
|
71
|
+
@fingerprint_mutex.synchronize do
|
|
72
|
+
@database_fingerprint = nil
|
|
73
|
+
@database_fingerprints = {}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
35
79
|
# Locate IP using MaxMind database
|
|
36
80
|
# @param ip [String] The IP address to locate
|
|
37
81
|
# @param request [ActionDispatch::Request, nil] Not used by MaxMind provider
|
|
@@ -40,13 +84,19 @@ module Trackdown
|
|
|
40
84
|
raise Trackdown::Error, "MaxMind database not found" unless Trackdown.database_exists?
|
|
41
85
|
raise Trackdown::Error, "maxmind-db gem not installed. Add it to your Gemfile: gem 'maxmind-db'" unless maxmind_available?
|
|
42
86
|
|
|
43
|
-
|
|
44
|
-
|
|
87
|
+
lookup = fetch_record(ip)
|
|
88
|
+
record = lookup.record
|
|
89
|
+
fingerprint = lookup.fingerprint
|
|
90
|
+
provenance = database_provenance(fingerprint)
|
|
91
|
+
|
|
92
|
+
# We looked, in this exact database, and this address simply isn't in it.
|
|
93
|
+
return LocationResult.unavailable(:address_not_found, **provenance) if record.nil?
|
|
45
94
|
|
|
46
95
|
country_code = extract_country_code(record)
|
|
47
96
|
country_name = extract_country_name(record)
|
|
48
97
|
city = extract_city(record)
|
|
49
98
|
flag_emoji = get_emoji_flag(country_code)
|
|
99
|
+
accuracy_radius = record&.dig('location', 'accuracy_radius')
|
|
50
100
|
|
|
51
101
|
LocationResult.new(
|
|
52
102
|
country_code, country_name, city, flag_emoji,
|
|
@@ -57,20 +107,37 @@ module Trackdown
|
|
|
57
107
|
latitude: record&.dig('location', 'latitude'),
|
|
58
108
|
longitude: record&.dig('location', 'longitude'),
|
|
59
109
|
postal_code: record&.dig('postal', 'code'),
|
|
60
|
-
metro_code: record&.dig('location', 'metro_code')&.to_s
|
|
110
|
+
metro_code: record&.dig('location', 'metro_code')&.to_s,
|
|
111
|
+
accuracy_radius_in_kilometers: accuracy_radius,
|
|
112
|
+
accuracy_radius_confidence_percentage: (ACCURACY_RADIUS_CONFIDENCE_PERCENTAGE if accuracy_radius),
|
|
113
|
+
**provenance
|
|
61
114
|
)
|
|
62
115
|
end
|
|
63
116
|
|
|
64
117
|
private
|
|
65
118
|
|
|
119
|
+
# Which database answered, plus how to identify it. The digest is a lambda
|
|
120
|
+
# so reading it stays optional: an ordinary lookup never re-reads the file.
|
|
121
|
+
def database_provenance(fingerprint)
|
|
122
|
+
{
|
|
123
|
+
provider_name: provider_name,
|
|
124
|
+
provider_source: provider_source,
|
|
125
|
+
database_build_epoch: fingerprint&.build_epoch,
|
|
126
|
+
database_sha256: (-> { fingerprint.sha256 } if fingerprint)
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
66
130
|
def maxmind_available?
|
|
67
131
|
defined?(MaxMind::DB)
|
|
68
132
|
end
|
|
69
133
|
|
|
70
134
|
def fetch_record(ip)
|
|
71
135
|
Timeout.timeout(Trackdown.configuration.timeout) do
|
|
72
|
-
reader_pool.with do |
|
|
73
|
-
reader.get(ip)
|
|
136
|
+
reader_pool.with do |database_reader|
|
|
137
|
+
record = database_reader.reader.get(ip)
|
|
138
|
+
fingerprint = remember_database(database_reader.fingerprint)
|
|
139
|
+
|
|
140
|
+
DatabaseLookup.new(record: record, fingerprint: fingerprint)
|
|
74
141
|
end
|
|
75
142
|
end
|
|
76
143
|
rescue Timeout::Error
|
|
@@ -83,21 +150,51 @@ module Trackdown
|
|
|
83
150
|
end
|
|
84
151
|
|
|
85
152
|
def reader_pool
|
|
86
|
-
return
|
|
153
|
+
return @reader_pool if @reader_pool
|
|
87
154
|
|
|
88
|
-
|
|
89
|
-
|
|
155
|
+
@pool_mutex.synchronize do
|
|
156
|
+
@reader_pool ||= ConnectionPool.new(
|
|
90
157
|
size: Trackdown.configuration.pool_size,
|
|
91
158
|
timeout: Trackdown.configuration.pool_timeout
|
|
92
159
|
) do
|
|
93
|
-
|
|
94
|
-
Trackdown.configuration.database_path,
|
|
95
|
-
mode: Trackdown.configuration.memory_mode
|
|
96
|
-
)
|
|
160
|
+
open_database_reader
|
|
97
161
|
end
|
|
98
162
|
end
|
|
99
163
|
end
|
|
100
164
|
|
|
165
|
+
# Capture the file identity before MaxMind opens it, then bind that exact
|
|
166
|
+
# identity to the reader for its whole lifetime. If the path changes while
|
|
167
|
+
# the reader opens, its eventual digest is nil rather than a digest from a
|
|
168
|
+
# different database generation.
|
|
169
|
+
def open_database_reader
|
|
170
|
+
path = Trackdown.configuration.database_path
|
|
171
|
+
fingerprint = DatabaseFingerprint.new(path: path)
|
|
172
|
+
reader = MaxMind::DB.new(path, mode: Trackdown.configuration.memory_mode)
|
|
173
|
+
fingerprint = fingerprint.with_build_epoch(build_epoch_of(reader))
|
|
174
|
+
|
|
175
|
+
DatabaseReader.new(reader: reader, fingerprint: canonical_fingerprint(fingerprint))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def remember_database(fingerprint)
|
|
179
|
+
@fingerprint_mutex.synchronize { @database_fingerprint = fingerprint }
|
|
180
|
+
fingerprint
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Reuse one lazy digest for every pooled reader that opened the same path,
|
|
184
|
+
# file identity, and database build. Readers from different generations
|
|
185
|
+
# always retain different fingerprint objects.
|
|
186
|
+
def canonical_fingerprint(fingerprint)
|
|
187
|
+
@fingerprint_mutex.synchronize do
|
|
188
|
+
@database_fingerprints[fingerprint.cache_key] ||= fingerprint
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_epoch_of(reader)
|
|
193
|
+
reader.metadata&.build_epoch
|
|
194
|
+
rescue StandardError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
101
198
|
def extract_country_code(record)
|
|
102
199
|
record&.dig('country', 'iso_code')
|
|
103
200
|
end
|
|
@@ -105,13 +202,13 @@ module Trackdown
|
|
|
105
202
|
def extract_country_name(record)
|
|
106
203
|
record&.dig('country', 'names', 'en') ||
|
|
107
204
|
(record&.dig('country', 'names')&.values&.first) ||
|
|
108
|
-
|
|
205
|
+
LocationResult::UNKNOWN
|
|
109
206
|
end
|
|
110
207
|
|
|
111
208
|
def extract_city(record)
|
|
112
209
|
record&.dig('city', 'names', 'en') ||
|
|
113
210
|
(record&.dig('city', 'names')&.values&.first) ||
|
|
114
|
-
|
|
211
|
+
LocationResult::UNKNOWN
|
|
115
212
|
end
|
|
116
213
|
|
|
117
214
|
def extract_region(record)
|
data/lib/trackdown/version.rb
CHANGED
data/lib/trackdown.rb
CHANGED
|
@@ -6,9 +6,11 @@ require_relative "trackdown/configuration"
|
|
|
6
6
|
require_relative "trackdown/ip_validator"
|
|
7
7
|
require_relative "trackdown/ip_locator"
|
|
8
8
|
require_relative "trackdown/database_updater"
|
|
9
|
+
require_relative "trackdown/database_fingerprint"
|
|
9
10
|
require_relative "trackdown/location_result"
|
|
10
11
|
require_relative "trackdown/providers/base_provider"
|
|
11
12
|
require_relative "trackdown/providers/cloudflare_provider"
|
|
13
|
+
require_relative "trackdown/providers/cloudfront_provider"
|
|
12
14
|
require_relative "trackdown/providers/maxmind_provider"
|
|
13
15
|
require_relative "trackdown/providers/auto_provider"
|
|
14
16
|
|
|
@@ -27,7 +29,7 @@ module Trackdown
|
|
|
27
29
|
|
|
28
30
|
# Locate an IP address using the configured provider
|
|
29
31
|
# @param ip [String] The IP address to locate
|
|
30
|
-
# @param request [
|
|
32
|
+
# @param request [#env, nil] Optional Rack-compatible request object (required for CDN providers)
|
|
31
33
|
# @return [LocationResult] The location information
|
|
32
34
|
def self.locate(ip, request: nil)
|
|
33
35
|
IpLocator.locate(ip, request: request)
|