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
|
@@ -3,15 +3,93 @@
|
|
|
3
3
|
require 'countries'
|
|
4
4
|
|
|
5
5
|
module Trackdown
|
|
6
|
+
# Where an IP address probably is — and an honest account of how we know.
|
|
7
|
+
#
|
|
8
|
+
# Every result carries its own provenance: which provider answered, from which
|
|
9
|
+
# source, when it answered, how precise that provider says the answer is, and
|
|
10
|
+
# whether the host vouched for the path the request arrived through.
|
|
11
|
+
#
|
|
12
|
+
# Nothing here is guessed. Every field is either what the provider said or
|
|
13
|
+
# something derived from it by a documented rule — never a plausible-looking
|
|
14
|
+
# placeholder. A field the answering provider cannot supply is `nil`, so a
|
|
15
|
+
# caller can always tell "the provider said no" apart from "it never said".
|
|
16
|
+
#
|
|
17
|
+
# GeoIP is an estimate. It never proves that a person or a device was in a
|
|
18
|
+
# place. MaxMind documents those limits exactly:
|
|
19
|
+
# https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy
|
|
6
20
|
class LocationResult
|
|
21
|
+
# What providers have always returned for a value they don't have. Kept for
|
|
22
|
+
# backwards compatibility — new code should ask #available? and read the
|
|
23
|
+
# nil-able fields instead of parsing display strings.
|
|
24
|
+
UNKNOWN = 'Unknown'
|
|
25
|
+
UNKNOWN_FLAG = '🏳️'
|
|
26
|
+
|
|
27
|
+
# Why a lookup came back with no location. Stable and machine-readable:
|
|
28
|
+
# these symbols are part of the public API and are never translated.
|
|
29
|
+
UNAVAILABLE_REASONS = %i[
|
|
30
|
+
no_provider_available
|
|
31
|
+
address_not_found
|
|
32
|
+
provider_returned_unknown_country
|
|
33
|
+
provider_data_incomplete
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# How much the host vouches for the source of a request-backed result.
|
|
37
|
+
# :host_verified only ever comes from the host's own verifier — see
|
|
38
|
+
# Trackdown::Configuration#verify_request_came_through_trusted_cloudflare_path_with
|
|
39
|
+
# and #verify_request_came_through_trusted_cloudfront_path_with.
|
|
40
|
+
SOURCE_TRUSTS = %i[unverified host_verified].freeze
|
|
41
|
+
|
|
42
|
+
# Where the IP is.
|
|
43
|
+
LOCATION_FIELDS = %i[
|
|
44
|
+
country_code country_name city flag_emoji
|
|
45
|
+
region region_code continent timezone
|
|
46
|
+
latitude longitude postal_code metro_code
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# How we know, and how sure we are.
|
|
50
|
+
PROVENANCE_FIELDS = %i[
|
|
51
|
+
provider_name provider_source source_trust resolved_at
|
|
52
|
+
available estimated unavailable_reason
|
|
53
|
+
accuracy_radius_in_kilometers accuracy_radius_confidence_percentage
|
|
54
|
+
database_build_epoch database_built_at database_sha256
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Every field #to_h can emit, in the order it emits them.
|
|
58
|
+
FIELDS = (LOCATION_FIELDS + PROVENANCE_FIELDS + %i[country_info]).freeze
|
|
59
|
+
|
|
60
|
+
# What a no-argument #to_h returns: the exact shape Trackdown returned before
|
|
61
|
+
# provenance existed, kept that way as an API compatibility guarantee. Ask for
|
|
62
|
+
# the rest with `include_provenance: true` or name it in `only:`.
|
|
63
|
+
DEFAULT_FIELDS = (LOCATION_FIELDS + %i[country_info]).freeze
|
|
64
|
+
|
|
65
|
+
# The digest is the one field that costs real work — a full read of the
|
|
66
|
+
# database file — so nothing hands it to you unless you say its name.
|
|
67
|
+
FIELDS_EXCEPT_DIGEST = (FIELDS - %i[database_sha256]).freeze
|
|
68
|
+
private_constant :FIELDS_EXCEPT_DIGEST
|
|
69
|
+
|
|
7
70
|
attr_reader :country_code, :country_name, :city, :flag_emoji,
|
|
8
71
|
:region, :region_code, :continent, :timezone, :latitude, :longitude,
|
|
9
|
-
:postal_code, :metro_code
|
|
72
|
+
:postal_code, :metro_code,
|
|
73
|
+
:provider_name, :provider_source, :source_trust, :resolved_at,
|
|
74
|
+
:unavailable_reason,
|
|
75
|
+
:accuracy_radius_in_kilometers, :accuracy_radius_confidence_percentage,
|
|
76
|
+
:database_build_epoch
|
|
10
77
|
|
|
78
|
+
# Every keyword is optional, so a provider only supplies what it actually knows.
|
|
79
|
+
#
|
|
80
|
+
# @param database_sha256 [String, #call, nil] the digest itself, or something
|
|
81
|
+
# that returns it, so an expensive digest is computed only if someone asks.
|
|
82
|
+
# Concurrent readers may each call it, so a callable should memoize its own
|
|
83
|
+
# work — Trackdown's does.
|
|
11
84
|
def initialize(country_code, country_name, city, flag_emoji,
|
|
12
85
|
region: nil, region_code: nil, continent: nil,
|
|
13
86
|
timezone: nil, latitude: nil, longitude: nil,
|
|
14
|
-
postal_code: nil, metro_code: nil
|
|
87
|
+
postal_code: nil, metro_code: nil,
|
|
88
|
+
provider_name: nil, provider_source: nil, source_trust: nil,
|
|
89
|
+
resolved_at: nil, unavailable_reason: nil,
|
|
90
|
+
accuracy_radius_in_kilometers: nil,
|
|
91
|
+
accuracy_radius_confidence_percentage: nil,
|
|
92
|
+
database_build_epoch: nil, database_sha256: nil)
|
|
15
93
|
@country_code = country_code
|
|
16
94
|
@country_name = country_name
|
|
17
95
|
@city = city
|
|
@@ -24,34 +102,161 @@ module Trackdown
|
|
|
24
102
|
@longitude = longitude
|
|
25
103
|
@postal_code = postal_code
|
|
26
104
|
@metro_code = metro_code
|
|
105
|
+
|
|
106
|
+
@provider_name = provider_name
|
|
107
|
+
@provider_source = provider_source
|
|
108
|
+
@source_trust = validate!(source_trust, SOURCE_TRUSTS, 'source trust')
|
|
109
|
+
@resolved_at = resolved_at || Time.now.utc
|
|
110
|
+
@unavailable_reason = validate!(unavailable_reason, UNAVAILABLE_REASONS, 'unavailable reason') ||
|
|
111
|
+
(:provider_data_incomplete if blank?(country_code))
|
|
112
|
+
@accuracy_radius_in_kilometers = accuracy_radius_in_kilometers
|
|
113
|
+
@accuracy_radius_confidence_percentage = accuracy_radius_confidence_percentage
|
|
114
|
+
@database_build_epoch = database_build_epoch
|
|
115
|
+
@database_sha256 = database_sha256
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A lookup that resolved nothing, and says why.
|
|
119
|
+
#
|
|
120
|
+
# LocationResult.unavailable(:address_not_found, provider_name: :maxmind)
|
|
121
|
+
def self.unavailable(reason, **provenance)
|
|
122
|
+
if reason.nil?
|
|
123
|
+
raise ArgumentError, "An unavailable result has to say why. Must be one of: #{UNAVAILABLE_REASONS.join(', ')}"
|
|
124
|
+
end
|
|
125
|
+
if provenance.key?(:unavailable_reason)
|
|
126
|
+
raise ArgumentError, 'Pass the unavailable reason once, as the first argument to LocationResult.unavailable'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
new(nil, UNKNOWN, UNKNOWN, UNKNOWN_FLAG, unavailable_reason: reason, **provenance)
|
|
27
130
|
end
|
|
28
131
|
|
|
29
132
|
alias_method :country, :country_name
|
|
30
133
|
alias_method :emoji, :flag_emoji
|
|
31
134
|
alias_method :emoji_flag, :flag_emoji
|
|
32
135
|
alias_method :country_flag, :flag_emoji
|
|
136
|
+
alias_method :provider, :provider_name
|
|
137
|
+
alias_method :accuracy_radius_km, :accuracy_radius_in_kilometers
|
|
138
|
+
|
|
139
|
+
# Did we actually resolve a location?
|
|
140
|
+
def available?
|
|
141
|
+
@unavailable_reason.nil?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def unavailable?
|
|
145
|
+
!available?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# True whenever a provider resolved any location data, including a partial
|
|
149
|
+
# result that has a city or coordinates but no country. Availability answers
|
|
150
|
+
# "could we name a country?"; estimated answers "is any returned location an
|
|
151
|
+
# inference?" Those are deliberately independent questions.
|
|
152
|
+
def estimated?
|
|
153
|
+
available? || partial_location_estimate?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# True only when the host's own verifier vouched for this request's path.
|
|
157
|
+
# Header presence alone never gets you here.
|
|
158
|
+
def source_was_verified_by_host?
|
|
159
|
+
@source_trust == :host_verified
|
|
160
|
+
end
|
|
161
|
+
alias_method :host_verified?, :source_was_verified_by_host?
|
|
162
|
+
|
|
163
|
+
# The MaxMind database digest, computed the first time it's asked for.
|
|
164
|
+
def database_sha256
|
|
165
|
+
@database_sha256 = @database_sha256.call if @database_sha256.respond_to?(:call)
|
|
166
|
+
@database_sha256
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# When the database that answered was built.
|
|
170
|
+
def database_built_at
|
|
171
|
+
Time.at(@database_build_epoch).utc if @database_build_epoch.is_a?(Numeric)
|
|
172
|
+
end
|
|
33
173
|
|
|
34
174
|
def country_info
|
|
35
175
|
return nil unless country_code
|
|
176
|
+
|
|
36
177
|
ISO3166::Country.new(country_code)
|
|
37
178
|
end
|
|
38
179
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
180
|
+
# The whole result as a hash, or exactly the fields you name.
|
|
181
|
+
#
|
|
182
|
+
# result.to_h
|
|
183
|
+
# result.to_h(only: %i[country_code city latitude longitude provider_name])
|
|
184
|
+
# result.to_h(include_country_info: false)
|
|
185
|
+
# result.to_h(include_provenance: true)
|
|
186
|
+
#
|
|
187
|
+
# @param only [Array<Symbol>, Symbol, nil] the exact fields to serialize, in
|
|
188
|
+
# the order you name them. What you name is what you get: naming a field
|
|
189
|
+
# that doesn't exist raises, and nothing you name is ever dropped, so a typo
|
|
190
|
+
# can't silently cost you a column in a record you're keeping. The full list
|
|
191
|
+
# of names is FIELDS.
|
|
192
|
+
# @param include_country_info [Boolean] the derived `countries` gem payload is
|
|
193
|
+
# large; pass false to leave it out of the default shape. Ignored when you
|
|
194
|
+
# pass `only:`, which already says exactly what you want.
|
|
195
|
+
# @param include_provenance [Boolean] add every provenance field except the
|
|
196
|
+
# database digest, whose first read costs a full pass over the database file.
|
|
197
|
+
# Ignored when `only:` names an exact shape.
|
|
198
|
+
def to_h(only: nil, include_country_info: true, include_provenance: false)
|
|
199
|
+
fields = if only
|
|
200
|
+
requested_fields(only)
|
|
201
|
+
elsif include_provenance
|
|
202
|
+
provenance_fields(include_country_info: include_country_info)
|
|
203
|
+
elsif include_country_info
|
|
204
|
+
DEFAULT_FIELDS
|
|
205
|
+
else
|
|
206
|
+
DEFAULT_FIELDS - %i[country_info]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
fields.to_h { |field| [field, value_of(field)] }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def value_of(field)
|
|
215
|
+
case field
|
|
216
|
+
when :available then available?
|
|
217
|
+
when :estimated then estimated?
|
|
218
|
+
when :country_info then country_info&.data || {}
|
|
219
|
+
else public_send(field)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def requested_fields(only)
|
|
224
|
+
fields = Array(only).map { |field| field.respond_to?(:to_sym) ? field.to_sym : field }
|
|
225
|
+
unknown = fields - FIELDS
|
|
226
|
+
|
|
227
|
+
unless unknown.empty?
|
|
228
|
+
raise ArgumentError, "Unknown #{unknown.one? ? 'field' : 'fields'} for LocationResult#to_h: " \
|
|
229
|
+
"#{unknown.map(&:inspect).join(', ')}. Available fields: #{FIELDS.join(', ')}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
fields.uniq
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def provenance_fields(include_country_info:)
|
|
236
|
+
return FIELDS_EXCEPT_DIGEST if include_country_info
|
|
237
|
+
|
|
238
|
+
FIELDS_EXCEPT_DIGEST - %i[country_info]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def validate!(value, allowed, description)
|
|
242
|
+
return nil if value.nil?
|
|
243
|
+
return value if allowed.include?(value)
|
|
244
|
+
|
|
245
|
+
raise ArgumentError, "Unknown #{description}: #{value.inspect}. Must be one of: #{allowed.join(', ')}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def blank?(value)
|
|
249
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def partial_location_estimate?
|
|
253
|
+
named_place = [@country_name, @city].any? { |value| !blank?(value) && value != UNKNOWN }
|
|
254
|
+
provider_fields = [
|
|
255
|
+
@region, @region_code, @continent, @timezone,
|
|
256
|
+
@latitude, @longitude, @postal_code, @metro_code
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
named_place || provider_fields.any? { |value| !blank?(value) }
|
|
55
260
|
end
|
|
56
261
|
end
|
|
57
262
|
end
|
|
@@ -1,25 +1,59 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
|
|
3
5
|
require_relative 'base_provider'
|
|
4
6
|
require_relative 'cloudflare_provider'
|
|
7
|
+
require_relative 'cloudfront_provider'
|
|
5
8
|
require_relative 'maxmind_provider'
|
|
6
9
|
|
|
7
10
|
module Trackdown
|
|
8
11
|
module Providers
|
|
9
12
|
# Intelligent provider that automatically selects the best available provider
|
|
10
|
-
#
|
|
11
|
-
# 1.
|
|
12
|
-
# 2. MaxMind
|
|
13
|
+
# Selection order:
|
|
14
|
+
# 1. Use the single edge provider whose client-IP header matches the target IP.
|
|
15
|
+
# 2. Try MaxMind, then return Unknown, when no edge provider can be verified.
|
|
16
|
+
# 3. Fail closed when both edge providers appear valid; header names alone cannot
|
|
17
|
+
# distinguish an authentic stacked-CDN request from forwarded viewer input.
|
|
13
18
|
#
|
|
14
19
|
# This is the recommended default for most applications
|
|
20
|
+
#
|
|
21
|
+
# IMPORTANT: When there's an upstream proxy before the CDN (e.g., a legacy
|
|
22
|
+
# API gateway), the CDN's geo headers will reflect the proxy's location, not
|
|
23
|
+
# the real client. AutoProvider detects this by comparing the CDN's own view
|
|
24
|
+
# of the client IP (CF-Connecting-IP for Cloudflare, CloudFront-Viewer-Address
|
|
25
|
+
# for CloudFront) with the passed IP and falls back to MaxMind (or Unknown when
|
|
26
|
+
# MaxMind is unavailable) on a mismatch.
|
|
27
|
+
#
|
|
28
|
+
# Cloudflare documents that CF-Connecting-IP is added only on edge-to-origin traffic:
|
|
29
|
+
# https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-connecting-ip
|
|
30
|
+
# AWS documents that the managed CloudFront policy forwards every viewer header,
|
|
31
|
+
# so CF-* names can still be viewer-controlled when they arrive through CloudFront:
|
|
32
|
+
# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
|
|
15
33
|
class AutoProvider < BaseProvider
|
|
16
|
-
|
|
17
|
-
|
|
34
|
+
CF_CONNECTING_IP_HEADER = 'HTTP_CF_CONNECTING_IP'
|
|
35
|
+
CLOUDFRONT_VIEWER_ADDRESS_HEADER = 'HTTP_CLOUDFRONT_VIEWER_ADDRESS'
|
|
36
|
+
|
|
37
|
+
@warned_no_providers = false
|
|
38
|
+
@warned_ip_mismatch = false
|
|
39
|
+
@warned_ambiguous_edge = false
|
|
40
|
+
@warn_mutex = Mutex.new
|
|
18
41
|
|
|
19
42
|
class << self
|
|
43
|
+
# :auto has no identity of its own. Every result names whichever provider
|
|
44
|
+
# actually won, and a result with no provider at all names none.
|
|
45
|
+
def provider_name
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def provider_source
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
20
53
|
# Auto provider is available if at least one provider is available
|
|
21
54
|
def available?(request: nil)
|
|
22
|
-
|
|
55
|
+
cloudflare_auto_available?(request) ||
|
|
56
|
+
cloudfront_auto_available?(request) ||
|
|
23
57
|
MaxmindProvider.available?(request: request)
|
|
24
58
|
end
|
|
25
59
|
|
|
@@ -28,34 +62,227 @@ module Trackdown
|
|
|
28
62
|
# @param request [ActionDispatch::Request, nil] Optional Rails request object
|
|
29
63
|
# @return [LocationResult] The location information
|
|
30
64
|
def locate(ip, request: nil)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return CloudflareProvider.locate(ip, request: request)
|
|
34
|
-
end
|
|
65
|
+
edge_provider = select_edge_provider(ip, request)
|
|
66
|
+
return edge_provider.locate(ip, request: request) if edge_provider
|
|
35
67
|
|
|
36
68
|
# Fall back to MaxMind if available
|
|
37
|
-
if MaxmindProvider.available?(request: request)
|
|
38
|
-
return MaxmindProvider.locate(ip, request: request)
|
|
39
|
-
end
|
|
69
|
+
return MaxmindProvider.locate(ip, request: request) if MaxmindProvider.available?(request: request)
|
|
40
70
|
|
|
41
71
|
# No providers available - fail gracefully with a warning
|
|
42
72
|
warn_no_providers
|
|
43
|
-
LocationResult.
|
|
73
|
+
LocationResult.unavailable(:no_provider_available)
|
|
44
74
|
end
|
|
45
75
|
|
|
46
76
|
private
|
|
47
77
|
|
|
78
|
+
def select_edge_provider(ip, request)
|
|
79
|
+
candidates = edge_candidates(ip, request)
|
|
80
|
+
verified_indices = candidates.each_index.select { |index| candidates[index][:matches] }
|
|
81
|
+
|
|
82
|
+
if verified_indices.length > 1
|
|
83
|
+
# The AWS managed policy forwards all viewer headers, so a CloudFront viewer
|
|
84
|
+
# can manufacture a matching CF-Connecting-IP. Conversely, Cloudflare forwards
|
|
85
|
+
# ordinary viewer headers, including CloudFront-* names. With both candidates
|
|
86
|
+
# matching, :auto has no authenticated signal that can safely break the tie.
|
|
87
|
+
warn_ambiguous_edge(ip)
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return warn_first_unverified(candidates, ip) if verified_indices.empty?
|
|
92
|
+
|
|
93
|
+
selected_index = verified_indices.first
|
|
94
|
+
skipped_candidate = candidates.first(selected_index).find { |candidate| candidate[:available] }
|
|
95
|
+
warn_unverified_candidate(skipped_candidate, ip) if skipped_candidate
|
|
96
|
+
candidates[selected_index][:provider]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def edge_candidates(ip, request)
|
|
100
|
+
cloudflare_available = CloudflareProvider.available?(request: request)
|
|
101
|
+
cloudfront_available = CloudfrontProvider.available?(request: request)
|
|
102
|
+
|
|
103
|
+
[
|
|
104
|
+
{
|
|
105
|
+
provider: CloudflareProvider,
|
|
106
|
+
name: 'Cloudflare',
|
|
107
|
+
header_name: 'CF-Connecting-IP',
|
|
108
|
+
header_value: request&.env&.dig(CF_CONNECTING_IP_HEADER),
|
|
109
|
+
available: cloudflare_available,
|
|
110
|
+
matches: cloudflare_available && cloudflare_ip_matches?(ip, request)
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
provider: CloudfrontProvider,
|
|
114
|
+
name: 'CloudFront',
|
|
115
|
+
header_name: 'CloudFront-Viewer-Address',
|
|
116
|
+
header_value: request&.env&.dig(CLOUDFRONT_VIEWER_ADDRESS_HEADER),
|
|
117
|
+
available: cloudfront_available,
|
|
118
|
+
matches: cloudfront_available && cloudfront_ip_matches?(ip, request)
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def warn_first_unverified(candidates, ip)
|
|
124
|
+
candidate = candidates.find { |item| item[:available] }
|
|
125
|
+
warn_unverified_candidate(candidate, ip) if candidate
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def warn_unverified_candidate(candidate, ip)
|
|
130
|
+
warn_ip_mismatch(
|
|
131
|
+
provider: candidate[:name],
|
|
132
|
+
ip: ip,
|
|
133
|
+
header_name: candidate[:header_name],
|
|
134
|
+
header_value: candidate[:header_value]
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Check if the IP we want to geolocate matches what Cloudflare saw as the client
|
|
139
|
+
# If they don't match, there's an upstream proxy and Cloudflare's geo headers are wrong
|
|
140
|
+
def cloudflare_ip_matches?(ip, request)
|
|
141
|
+
return false unless request
|
|
142
|
+
|
|
143
|
+
cf_connecting_ip = request.env[CF_CONNECTING_IP_HEADER]
|
|
144
|
+
return false unless cf_connecting_ip.is_a?(String)
|
|
145
|
+
return false if cf_connecting_ip.empty?
|
|
146
|
+
|
|
147
|
+
# Normalize IPs for comparison (handle IPv6 formatting differences)
|
|
148
|
+
normalized_ip = normalize_ip(ip)
|
|
149
|
+
normalized_cf_ip = normalize_ip(cf_connecting_ip)
|
|
150
|
+
!normalized_ip.nil? && normalized_ip == normalized_cf_ip
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check if the IP we want to geolocate matches what CloudFront saw as the client.
|
|
154
|
+
# CloudFront-Viewer-Address is "IP:port" (the port trails the last colon, which
|
|
155
|
+
# also works for IPv6 since the address itself contains colons).
|
|
156
|
+
def cloudfront_ip_matches?(ip, request)
|
|
157
|
+
return false unless request
|
|
158
|
+
|
|
159
|
+
normalized_ip = normalize_ip(ip)
|
|
160
|
+
cloudfront_ip = cloudfront_viewer_ip(request)
|
|
161
|
+
!normalized_ip.nil? && normalized_ip == cloudfront_ip
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def cloudflare_auto_available?(request)
|
|
165
|
+
CloudflareProvider.available?(request: request) &&
|
|
166
|
+
!normalize_ip(request.env[CF_CONNECTING_IP_HEADER]).nil?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def cloudfront_auto_available?(request)
|
|
170
|
+
CloudfrontProvider.available?(request: request) && !cloudfront_viewer_ip(request).nil?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# AWS specifies CloudFront-Viewer-Address as the viewer IP followed by its
|
|
174
|
+
# source port. Split on the final colon for AWS's unbracketed IPv6 form, while
|
|
175
|
+
# also accepting RFC 3986 bracketed IP literals defensively.
|
|
176
|
+
# AWS: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
|
|
177
|
+
# RFC 3986: https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
|
|
178
|
+
# Port range/reserved zero: https://www.rfc-editor.org/rfc/rfc6335#section-6
|
|
179
|
+
def cloudfront_viewer_ip(request)
|
|
180
|
+
return nil unless request
|
|
181
|
+
|
|
182
|
+
viewer_address = request.env[CLOUDFRONT_VIEWER_ADDRESS_HEADER]
|
|
183
|
+
return nil unless viewer_address.is_a?(String)
|
|
184
|
+
|
|
185
|
+
value = viewer_address.strip
|
|
186
|
+
return nil if value.empty?
|
|
187
|
+
|
|
188
|
+
match = value.match(/\A\[(.+)\]:(\d+)\z/)
|
|
189
|
+
if match
|
|
190
|
+
ip = match[1]
|
|
191
|
+
port = match[2]
|
|
192
|
+
else
|
|
193
|
+
ip, separator, port = value.rpartition(':')
|
|
194
|
+
return nil if separator.empty?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
return nil unless valid_source_port?(port)
|
|
198
|
+
|
|
199
|
+
normalize_ip(ip)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def valid_source_port?(port)
|
|
203
|
+
return false unless /\A\d{1,5}\z/.match?(port)
|
|
204
|
+
|
|
205
|
+
(1..65_535).cover?(Integer(port, 10))
|
|
206
|
+
rescue ArgumentError
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def normalize_ip(ip)
|
|
211
|
+
return nil unless ip.is_a?(String)
|
|
212
|
+
|
|
213
|
+
value = ip.to_s.strip
|
|
214
|
+
return nil if value.empty?
|
|
215
|
+
|
|
216
|
+
parsed_ip = IPAddr.new(value)
|
|
217
|
+
# IPAddr normalizes equivalent IPv6 spellings; #native converts an
|
|
218
|
+
# IPv4-mapped IPv6 address to its native IPv4 representation.
|
|
219
|
+
# https://docs.ruby-lang.org/en/3.3/IPAddr.html
|
|
220
|
+
# https://docs.ruby-lang.org/en/3.3/IPAddr.html#method-i-native
|
|
221
|
+
parsed_ip = parsed_ip.native if parsed_ip.ipv4_mapped?
|
|
222
|
+
parsed_ip.to_s.downcase
|
|
223
|
+
rescue IPAddr::Error
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def warn_ip_mismatch(provider:, ip:, header_name:, header_value:)
|
|
228
|
+
return if @warned_ip_mismatch
|
|
229
|
+
|
|
230
|
+
warn_mutex.synchronize do
|
|
231
|
+
return if @warned_ip_mismatch
|
|
232
|
+
|
|
233
|
+
@warned_ip_mismatch = true
|
|
234
|
+
|
|
235
|
+
reason = if header_value.nil? || (header_value.is_a?(String) && header_value.empty?)
|
|
236
|
+
"#{header_name} is missing"
|
|
237
|
+
elsif !header_value.is_a?(String)
|
|
238
|
+
"#{header_name} is malformed"
|
|
239
|
+
else
|
|
240
|
+
"#{header_name} (#{header_value}) does not match the request IP (#{ip})"
|
|
241
|
+
end
|
|
242
|
+
message = "[Trackdown] Cannot verify #{provider} geolocation because #{reason}. " \
|
|
243
|
+
"Skipping #{provider} and trying the next available provider."
|
|
244
|
+
|
|
245
|
+
if defined?(Rails)
|
|
246
|
+
Rails.logger.info(message)
|
|
247
|
+
else
|
|
248
|
+
warn(message)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def warn_ambiguous_edge(ip)
|
|
254
|
+
return if @warned_ambiguous_edge
|
|
255
|
+
|
|
256
|
+
warn_mutex.synchronize do
|
|
257
|
+
return if @warned_ambiguous_edge
|
|
258
|
+
|
|
259
|
+
@warned_ambiguous_edge = true
|
|
260
|
+
|
|
261
|
+
message = "[Trackdown] Both Cloudflare and CloudFront headers match request IP (#{ip}). " \
|
|
262
|
+
'Because forwarded viewer headers can spoof this combination, :auto cannot ' \
|
|
263
|
+
'choose safely. Trying MaxMind and otherwise returning Unknown; configure ' \
|
|
264
|
+
'an explicit provider if this is an intentional stacked-CDN deployment.'
|
|
265
|
+
|
|
266
|
+
if defined?(Rails)
|
|
267
|
+
Rails.logger.warn(message)
|
|
268
|
+
else
|
|
269
|
+
warn(message)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
48
274
|
def warn_no_providers
|
|
49
275
|
# Only warn once per process to avoid log spam
|
|
50
|
-
return if
|
|
276
|
+
return if @warned_no_providers
|
|
277
|
+
|
|
278
|
+
warn_mutex.synchronize do
|
|
279
|
+
return if @warned_no_providers
|
|
51
280
|
|
|
52
|
-
|
|
53
|
-
return if @@warned_no_providers
|
|
54
|
-
@@warned_no_providers = true
|
|
281
|
+
@warned_no_providers = true
|
|
55
282
|
|
|
56
283
|
message = "[Trackdown] No IP geolocation provider available. Returning 'Unknown' for all lookups. " \
|
|
57
|
-
|
|
58
|
-
|
|
284
|
+
'Configure verified Cloudflare or CloudFront headers, or MaxMind, to enable geolocation. ' \
|
|
285
|
+
'See: https://github.com/rameerez/trackdown'
|
|
59
286
|
|
|
60
287
|
if defined?(Rails)
|
|
61
288
|
Rails.logger.warn(message)
|
|
@@ -64,6 +291,10 @@ module Trackdown
|
|
|
64
291
|
end
|
|
65
292
|
end
|
|
66
293
|
end
|
|
294
|
+
|
|
295
|
+
def warn_mutex
|
|
296
|
+
@warn_mutex ||= Mutex.new
|
|
297
|
+
end
|
|
67
298
|
end
|
|
68
299
|
end
|
|
69
300
|
end
|