trackdown 0.3.1 → 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.
data/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  `trackdown` is a Ruby gem that allows you to geolocate IP addresses easily.
9
9
 
10
- It works out-of-the-box with **Cloudflare** (zero config!); and it's also a simple, convenient wrapper on top of **MaxMind** (just bring your own MaxMind key, and you're good to go!).
10
+ It reads geolocation headers from **Cloudflare** and **Amazon CloudFront** without API calls or additional runtime gems, and it is also a convenient wrapper around **MaxMind**. CDN-side header forwarding and origin protection still need to be configured correctly; the exact requirements are documented below.
11
11
 
12
12
  `trackdown` offers a clean API for Rails applications to fetch country, city, region, continent, timezone, coordinates, and emoji flag information for any IP address.
13
13
 
@@ -22,37 +22,60 @@ Given an IP, it gives you the corresponding:
22
22
  - 📺 Metro code (e.g. "807")
23
23
  - 🇺🇸 Emoji flag of the country
24
24
 
25
+ And, because a location you can't explain isn't much of a location, every result also tells you [how it knows](#how-do-you-know): which provider answered, when, how precise that provider says it is, whether you vouched for the request it came from, and — when there's no answer — exactly why.
26
+
25
27
  ## First, choose your `trackdown` Geo IP provider
26
28
 
27
- ### Option 1: Cloudflare (recommended, zero config)
29
+ ### Option 1: Cloudflare (recommended for Cloudflare origins)
28
30
 
29
- If your Rails app is behind Cloudflare, you can use `trackdown` with **zero configuration**:
31
+ If your Rails app is behind Cloudflare, `trackdown` reads the location information Cloudflare adds to origin requests:
30
32
  - No API keys needed
31
33
  - No database downloads
32
34
  - No external dependencies
33
35
  - Instant lookups from Cloudflare headers
34
36
 
35
- Just enable "IP Geolocation" in your Cloudflare dashboard and you're done! For the full set of location fields (city, region, coordinates, etc.), enable ["Add visitor location headers"](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/) in Managed Transforms. We automatically read these headers from the `request` and provide you with the IP geo data.
37
+ Enable "IP Geolocation" in your Cloudflare dashboard. For the full set of location fields (city, region, coordinates, etc.), enable ["Add visitor location headers"](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/) in Managed Transforms. `:auto` also verifies that the documented [`CF-Connecting-IP` edge-to-origin header](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-connecting-ip) matches the IP passed to `Trackdown.locate` before trusting the location headers. If Cloudflare's "Remove visitor IP headers" transform suppresses that corroborator, use an explicitly configured provider only after securing the origin.
38
+
39
+ As with every header-based provider, direct-origin access must be blocked. Cloudflare recommends [blocking traffic that does not come from Cloudflare IPs](https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/#block-other-ip-addresses-recommended) or using [Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/) to ensure requests came through its network.
40
+
41
+ ### Option 2: Amazon CloudFront (recommended for CloudFront origins)
42
+
43
+ If your Rails app is behind Amazon CloudFront, `trackdown` can read CloudFront's viewer-location headers:
44
+ - No API keys needed
45
+ - No database downloads
46
+ - No external dependencies
47
+ - Instant lookups from CloudFront `CloudFront-Viewer-*` headers
48
+
49
+ CloudFront requires explicit distribution and origin configuration:
50
+
51
+ 1. Attach an [origin request policy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html) that adds the viewer-location headers. A custom least-privilege policy containing only the required `CloudFront-*` headers is preferred. AWS's managed [`AllViewerAndCloudFrontHeaders-2022-06` policy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront) includes them, but also forwards **every viewer header, cookie, and query string**.
52
+ 2. Prevent direct access to the origin. Header presence alone does not prove that a request passed through CloudFront. AWS documents how to [add an origin-only custom header](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html) and [configure a custom origin to accept only CloudFront requests](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html).
53
+ 3. Pass the request and the intended viewer IP: `Trackdown.locate(request.remote_ip, request: request)`.
54
+
55
+ AWS documents the exact [viewer-location header names, availability rules, address format, and RFC 3986 encoding](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location). `trackdown` validates the country, decodes percent-encoded UTF-8 fields, validates coordinate bounds, and—under `:auto`—requires `CloudFront-Viewer-Address` to match the requested IP.
36
56
 
37
- ### Option 2: MaxMind (BYOK - Bring Your Own Key)
57
+ > [!IMPORTANT]
58
+ > If both Cloudflare and CloudFront header families match the target IP, `:auto` fails closed because viewer-forwarded headers make the situation ambiguous. It tries MaxMind and otherwise returns `'Unknown'`. Choose `config.provider = :cloudflare` or `:cloudfront` for an intentional stacked-CDN deployment after securing the origin.
38
59
 
39
- For apps not behind Cloudflare, offline apps, non-Rails apps, or as a fallback, use MaxMind:
60
+ ### Option 3: MaxMind (BYOK - Bring Your Own Key)
61
+
62
+ For apps not behind a supported CDN, offline apps, non-Rails apps, or as a fallback, use MaxMind:
40
63
  - Requires MaxMind account and license key
41
64
  - Requires downloading and maintaining a local database
42
65
  - Works offline once database is downloaded
43
66
  - Get started at [MaxMind](https://www.maxmind.com/)
44
67
 
45
- ### Option 3: Auto
68
+ ### Option 4: Auto
46
69
 
47
- By default, `trackdown` uses **`:auto` mode** which tries Cloudflare first and falls back to MaxMind automatically.
70
+ By default, `trackdown` uses **`:auto` mode**. It uses an edge provider only when that provider's documented client-IP header matches the target IP. When no unique edge provider can be verified, it tries MaxMind and otherwise returns `'Unknown'`.
48
71
 
49
72
  > [!NOTE]
50
- > Trackdown fails gracefully. If no provider is available (no Cloudflare headers, no MaxMind database), it returns `'Unknown'` instead of raising an error, so your app doesn't crash due to a missing geolocation provider.
73
+ > Trackdown fails gracefully. If no provider is available (no verified CDN headers and no MaxMind database), it returns `'Unknown'` instead of raising an error, so your app doesn't crash due to a missing geolocation provider.
51
74
 
52
75
 
53
76
  ## Installation
54
77
 
55
- Add this line to your application's Gemfile:
78
+ `trackdown` needs Ruby 3.1 or newer. Add this line to your application's Gemfile:
56
79
 
57
80
  ```ruby
58
81
  gem 'trackdown'
@@ -81,9 +104,40 @@ If your app is behind Cloudflare, setup is super simple:
81
104
  ```ruby
82
105
  # In your controller
83
106
  Trackdown.locate(request.remote_ip, request: request).country
84
- # => 'United States'
107
+ # => 'United States of America'
108
+ ```
109
+
110
+ ### Setup with Amazon CloudFront
111
+
112
+ 1. Create an origin request policy that adds these headers:
113
+ - `CloudFront-Viewer-Country`
114
+ - `CloudFront-Viewer-City`
115
+ - `CloudFront-Viewer-Country-Region-Name`
116
+ - `CloudFront-Viewer-Country-Region`
117
+ - `CloudFront-Viewer-Latitude`
118
+ - `CloudFront-Viewer-Longitude`
119
+ - `CloudFront-Viewer-Time-Zone`
120
+ - `CloudFront-Viewer-Postal-Code`
121
+ - `CloudFront-Viewer-Metro-Code`
122
+ - `CloudFront-Viewer-Address` (required for `:auto` IP corroboration)
123
+
124
+ AWS source for creating and attaching origin request policies:
125
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html
126
+
127
+ 2. Restrict the custom origin so viewers cannot bypass CloudFront and forge these headers. AWS's documented mechanism is an origin custom header that the origin requires and that CloudFront overwrites before forwarding:
128
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html
129
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html
130
+
131
+ 3. Use the request-bound API:
132
+
133
+ ```ruby
134
+ Trackdown.locate(request.remote_ip, request: request).country
135
+ # => 'United States of America'
85
136
  ```
86
137
 
138
+ For a distribution dedicated to this application, use a custom policy containing only the required headers. If you instead use AWS's managed `AllViewerAndCloudFrontHeaders-2022-06` policy, remember that AWS documents it as forwarding all viewer headers, cookies, and query strings:
139
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
140
+
87
141
  ### Setup with MaxMind
88
142
 
89
143
  If you want to use `trackdown` with a MaxMind database as the geo IP data provider:
@@ -142,15 +196,17 @@ production:
142
196
 
143
197
  ## Usage
144
198
 
145
- ### With Cloudflare (recommended when available)
199
+ ### With Cloudflare or CloudFront
146
200
 
147
201
  ```ruby
148
202
  # In your controller - pass the request object
149
203
  result = Trackdown.locate(request.remote_ip, request: request)
150
204
  result.country
151
- # => 'United States'
205
+ # => 'United States of America'
152
206
  ```
153
207
 
208
+ In `:auto`, `request.remote_ip` must represent the same viewer that the CDN's corroborating IP header represents. If your Rails proxy configuration deliberately produces a different IP, use MaxMind for that target or explicitly select the correctly configured CDN provider.
209
+
154
210
  ### With MaxMind or without request object
155
211
 
156
212
  To geolocate an IP address:
@@ -174,9 +230,9 @@ In fact, there are a few methods you can use:
174
230
 
175
231
  ```ruby
176
232
  result.country_code # => 'US'
177
- result.country_name # => 'United States'
233
+ result.country_name # => 'United States' (MaxMind's own name for it)
178
234
  result.country # => 'United States' (alias for country_name)
179
- result.city # => 'Mountain View' (from MaxMind or Cloudflare's "Add visitor location headers")
235
+ result.city # => 'Mountain View' (from MaxMind or configured CDN headers)
180
236
  result.region # => 'California'
181
237
  result.region_code # => 'CA'
182
238
  result.continent # => 'NA'
@@ -191,8 +247,21 @@ result.country_flag # => '🇺🇸' (alias for flag_emoji)
191
247
  result.country_info # => # Rich country data from the `countries` gem
192
248
  ```
193
249
 
250
+ `country_name` comes from MaxMind's own record on the MaxMind path, and from the [`countries` gem](https://github.com/countries/countries) on the Cloudflare and CloudFront paths — so the same country can read `'United States'` or `'United States of America'` depending on who answered. `country_code` is the one to compare against.
251
+
252
+ And the same result will tell you where all of that came from:
253
+
254
+ ```ruby
255
+ result.available? # => true (did we actually resolve a location?)
256
+ result.provider_name # => :maxmind
257
+ result.provider # => :maxmind (alias for provider_name)
258
+ result.resolved_at # => 2026-08-15 04:22:47 UTC
259
+ ```
260
+
261
+ There's [a whole section on that](#how-do-you-know) below.
262
+
194
263
  > [!NOTE]
195
- > The `region`, `region_code`, `continent`, `timezone`, `latitude`, `longitude`, `postal_code`, and `metro_code` fields require Cloudflare's ["Add visitor location headers"](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/) Managed Transform to be enabled, or a MaxMind GeoLite2-City database. These fields return `nil` when not available.
264
+ > The optional fields require Cloudflare's ["Add visitor location headers"](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/), an applicable CloudFront origin request policy, or a MaxMind GeoLite2-City database. AWS notes that city, metro, and postal data may be unavailable and that extended CloudFront location headers are omitted for viewers on AWS networks: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location. Unavailable fields return `nil` (`city` uses `'Unknown'`).
196
265
 
197
266
  ### Rich country information
198
267
 
@@ -208,7 +277,8 @@ result.country_info.iso_long_name # => 'The United States of America'
208
277
 
209
278
  ### Hash data
210
279
 
211
- If you prefer, you can also get all the information as a hash:
280
+ If you prefer a hash, the no-argument form keeps Trackdown's original 13-key
281
+ shape exactly:
212
282
 
213
283
  ```ruby
214
284
  result.to_h
@@ -229,14 +299,277 @@ result.to_h
229
299
  # }
230
300
  ```
231
301
 
302
+ Ask for provenance when you want the provenance-rich shape:
303
+
304
+ ```ruby
305
+ result.to_h(include_provenance: true)
306
+ # => {
307
+ # country_code: 'US',
308
+ # country_name: 'United States',
309
+ # city: 'Mountain View',
310
+ # flag_emoji: '🇺🇸',
311
+ # region: 'California',
312
+ # region_code: 'CA',
313
+ # continent: 'NA',
314
+ # timezone: 'America/Los_Angeles',
315
+ # latitude: 37.7749,
316
+ # longitude: -122.4194,
317
+ # postal_code: '94107',
318
+ # metro_code: '807',
319
+ # provider_name: :maxmind,
320
+ # provider_source: :maxmind_local_database,
321
+ # source_trust: nil,
322
+ # resolved_at: 2026-08-15 04:22:47 UTC,
323
+ # available: true,
324
+ # estimated: true,
325
+ # unavailable_reason: nil,
326
+ # accuracy_radius_in_kilometers: 20,
327
+ # accuracy_radius_confidence_percentage: 67,
328
+ # database_build_epoch: 1735689600,
329
+ # database_built_at: 2025-01-01 00:00:00 UTC,
330
+ # country_info: { ... }
331
+ # }
332
+ ```
333
+
334
+ Or just the fields you actually want to keep, named in plain English, in the order you name them:
335
+
336
+ ```ruby
337
+ result.to_h(only: %i[country_code city latitude longitude accuracy_radius_in_kilometers provider_name])
338
+ # => { country_code: 'US', city: 'Mountain View', latitude: 37.7749,
339
+ # longitude: -122.4194, accuracy_radius_in_kilometers: 20, provider_name: :maxmind }
340
+ ```
341
+
342
+ `country_info` is a large derived payload, so you can also just leave it out:
343
+
344
+ ```ruby
345
+ result.to_h(include_country_info: false)
346
+ ```
347
+
348
+ What you name is what you get, in that order — naming a field that doesn't exist raises, and nothing you name is ever dropped, so a typo can't quietly cost you a column in something you're storing.
349
+
350
+ > [!NOTE]
351
+ > The no-argument `to_h` is deliberately backward compatible: same keys, same order, same values. `include_provenance: true` opts into every provenance field except `database_sha256`; a digest can require reading the whole database file, so Trackdown never hides that I/O inside ordinary serialization. Ask for the digest explicitly with `only:` — [see below](#which-database-said-so).
352
+
353
+ ## How do you know?
354
+
355
+ Geolocating an IP is a guess. A good one, but a guess — and if you're using it for fraud checks, abuse reports, audit trails, or anything you might one day have to explain, the guess is only half of what you need. You also need to know *how it was made*.
356
+
357
+ So every result carries its own provenance. Nothing here is guessed: every field is either what the provider said, or something derived from it by a rule this README documents. A field the answering provider can't supply is `nil`, never a plausible-looking placeholder.
358
+
359
+ ### Which provider answered, and when
360
+
361
+ ```ruby
362
+ result.provider_name # => :cloudflare, :cloudfront, or :maxmind
363
+ result.provider_source # => :cloudflare_request_headers, :cloudfront_request_headers, or :maxmind_local_database
364
+ result.resolved_at # => 2026-08-15 04:22:47 UTC
365
+ result.estimated? # => true
366
+ ```
367
+
368
+ `provider_name` uses the very same symbols you'd set as `config.provider`, so `result.provider_name == :cloudflare` reads exactly like the config that produced it. In `:auto` mode you get the provider that actually won, after every fallback — if Cloudflare was skipped and MaxMind answered, the result says `:maxmind`.
369
+
370
+ `estimated?` is `true` whenever the provider returned any location estimate. That
371
+ includes a partial result with a city or coordinates but no country, even though
372
+ that same result is `unavailable?`. It is `false` when nothing spatial was
373
+ resolved—for example, a bare Cloudflare `T1` Tor marker. `available?` answers
374
+ "could we name a country?"; `estimated?` answers "did the provider return any
375
+ inferred location?" Those are intentionally independent questions.
376
+
377
+ GeoIP never proves that a person or device was at a location. MaxMind explicitly
378
+ says its data cannot identify a specific household, individual, or street address
379
+ and may locate a VPN or server rather than its end user:
380
+ https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy
381
+
382
+ ### Did we actually find anything?
383
+
384
+ ```ruby
385
+ result.available? # => false
386
+ result.unavailable? # => true
387
+ result.unavailable_reason # => :address_not_found
388
+ ```
389
+
390
+ The reasons are stable symbols, part of the public API, and never translated:
391
+
392
+ | Reason | What happened |
393
+ |---|---|
394
+ | `:no_provider_available` | No usable CDN header result and no MaxMind database. Nobody could answer. |
395
+ | `:address_not_found` | We searched a real database and this address simply isn't in it. |
396
+ | `:provider_returned_unknown_country` | The CDN answered, but with no country — Cloudflare's `XX`, Unicode's unknown/invalid `ZZ`, or `T1` for a visitor arriving over Tor. |
397
+ | `:provider_data_incomplete` | A provider returned a record, but not enough of one to name a country. |
398
+
399
+ `unavailable?` means precisely *"we could not name a country"*. Some of those results still carry something useful — a Tor result keeps `country_code == 'T1'`, and an incomplete database record can still have a city and coordinates. If those are worth having to you, read them; Trackdown hands back everything it got either way.
400
+
401
+ > [!NOTE]
402
+ > `country_name` and `city` still return the display string `'Unknown'` when a provider has no value, exactly as they always have — existing code keeps working. New code should branch on `available?` / `unavailable_reason` rather than comparing against a display string.
403
+
404
+ ### How precise is it?
405
+
406
+ MaxMind's City records carry an accuracy radius, and Trackdown passes it straight through:
407
+
408
+ ```ruby
409
+ result.accuracy_radius_in_kilometers # => 20
410
+ result.accuracy_radius_km # => 20 (alias)
411
+ result.accuracy_radius_confidence_percentage # => 67
412
+ ```
413
+
414
+ That reads: *the address is within 20 km of these coordinates, with 67% confidence* — [MaxMind's own definition](https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy). Neither Cloudflare's [exact visitor-location field list](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers) nor CloudFront's [exact viewer-location header list](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location) publishes an accuracy figure, so those results return `nil` instead of an invented one.
415
+
416
+ ### Which database said so?
417
+
418
+ ```ruby
419
+ result.database_build_epoch # => 1735689600
420
+ result.database_built_at # => 2025-01-01 00:00:00 UTC
421
+ result.database_sha256 # => '4f8b42c22dd3729b519ba6f68d2da7cc…'
422
+ ```
423
+
424
+ The build epoch comes from the exact answering reader's database metadata; the
425
+ MaxMind DB format defines it as the database build timestamp in Unix-epoch form:
426
+ https://maxmind.github.io/MaxMind-DB/#build_epoch. In a serialized hash it is
427
+ included only when you ask for provenance or name it with `only:`.
428
+
429
+ The digest is lazy because it costs a full read of the database file. Trackdown
430
+ computes it the first time someone asks and shares that lazy fingerprint among
431
+ pooled readers bound to the same path, file identity, and build epoch. Ordinary
432
+ lookups and ordinary `to_h` calls never compute it. When you want it, name it:
433
+
434
+ ```ruby
435
+ result.database_sha256 # the reader
436
+ result.to_h(only: %i[database_sha256]) # or in a hash
437
+ ```
438
+
439
+ Every result retains the fingerprint of the exact reader that answered it. That
440
+ matters because the MaxMind Ruby reader can either copy the file into memory when
441
+ opened or retain an open file handle, depending on mode:
442
+ https://github.com/maxmind/MaxMind-DB-Reader-ruby/blob/v1.2.0/lib/maxmind/db/memory_reader.rb#L7-L15
443
+ and
444
+ https://github.com/maxmind/MaxMind-DB-Reader-ruby/blob/v1.2.0/lib/maxmind/db/file_reader.rb#L36-L55.
445
+ If the configured path is replaced after an older reader opened it, the older
446
+ result keeps that reader's build epoch and its digest becomes `nil`; it never
447
+ borrows the replacement file's digest. A new reader receives a new fingerprint.
448
+
449
+ `Trackdown.update_database` writes the complete download to a temporary file in
450
+ the destination directory, flushes it, and replaces the configured path with one
451
+ `File.rename` only after the archive contains a `.mmdb` file. That prevents
452
+ Trackdown's updater from truncating a database underneath a `MODE_FILE` reader.
453
+ Ruby's rename contract is documented at
454
+ https://docs.ruby-lang.org/en/3.3/File.html#method-c-rename, and the reader's
455
+ open-file behavior is visible in the exact source linked above.
456
+
457
+ > [!NOTE]
458
+ > `Trackdown.update_database` drops the cached reader pool in the process that ran it, so that process's next lookup opens the new database. If you refresh from a separate process — a cron job or a `rails runner`, as the scheduling section recommends — your web workers keep serving the database they already have open until they restart. Call `Trackdown::Providers::MaxmindProvider.reset_database!` in a worker to make its next lookup pick up the new file without a restart.
459
+
460
+ ### Did the request really come through your CDN?
461
+
462
+ Here's the uncomfortable part. `CF-IPCountry` is just a header. Anyone who can reach your origin directly can send you one, and it will look exactly like the real thing. Matching `CF-Connecting-IP` against the IP you're asking about — which `:auto` already does — is useful corroboration, but it is *not* proof that the request came through Cloudflare.
463
+
464
+ Only your own origin protection can vouch for the request path. Cloudflare and
465
+ CloudFront are configured independently: a verified path through one CDN must
466
+ never authenticate the other CDN's headers.
467
+
468
+ For Cloudflare, have the layer that actually validates Authenticated Origin Pulls
469
+ or the Cloudflare peer network place a non-viewer-controlled boolean in the Rack
470
+ environment, then read that boolean:
471
+
472
+ ```ruby
473
+ Trackdown.configure do |config|
474
+ config.verify_request_came_through_trusted_cloudflare_path_with do |request|
475
+ request.env['my_app.cloudflare_origin_was_verified'] == true
476
+ end
477
+ end
478
+ ```
479
+
480
+ For a CloudFront custom origin header, compare the CloudFront-only secret and
481
+ refuse to boot if the expected value is blank:
482
+
483
+ ```ruby
484
+ expected_cloudfront_origin_secret =
485
+ Rails.application.credentials.dig(:cloudfront, :origin_secret).to_s
486
+ raise 'Missing CloudFront origin secret' if expected_cloudfront_origin_secret.empty?
487
+
488
+ Trackdown.configure do |config|
489
+ config.verify_request_came_through_trusted_cloudfront_path_with do |request|
490
+ supplied_cloudfront_origin_secret =
491
+ request.env['HTTP_X_CLOUDFRONT_ORIGIN_SECRET'].to_s
492
+
493
+ !supplied_cloudfront_origin_secret.empty? &&
494
+ ActiveSupport::SecurityUtils.secure_compare(
495
+ supplied_cloudfront_origin_secret,
496
+ expected_cloudfront_origin_secret
497
+ )
498
+ end
499
+ end
500
+ ```
501
+
502
+ Both non-empty checks matter. Rails implements `secure_compare` as an equal-byte-
503
+ length check followed by a fixed-length comparison, so two empty strings compare
504
+ equal:
505
+ https://api.rubyonrails.org/classes/ActiveSupport/SecurityUtils.html#method-c-secure_compare
506
+
507
+ ```ruby
508
+ result.source_trust # => :host_verified (or :unverified)
509
+ result.source_was_verified_by_host? # => true
510
+ result.host_verified? # => true (alias)
511
+ ```
512
+
513
+ Without that callback, a request-backed result is always `:unverified` — no matter how complete or how corroborated its headers are. Header presence alone can never produce `:host_verified`. MaxMind results have no request path to verify at all, so their `source_trust` is `nil`.
514
+
515
+ What you put in each callback is whatever that deployment path actually proves:
516
+
517
+ - **Cloudflare:** [Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/), or [blocking every IP that isn't Cloudflare's](https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/#block-other-ip-addresses-recommended).
518
+ - **CloudFront:** [an origin custom header CloudFront adds and viewers can't](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html), plus [restricting the custom origin](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html).
519
+
520
+ The separation is security-relevant. Cloudflare documents that it passes ordinary
521
+ viewer request headers to the origin:
522
+ https://developers.cloudflare.com/fundamentals/reference/http-headers/#request-headers.
523
+ AWS documents that `AllViewerAndCloudFrontHeaders-2022-06` forwards all viewer
524
+ headers:
525
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront.
526
+ Therefore a trusted CloudFront path cannot vouch for a forwarded `CF-*` family,
527
+ and a trusted Cloudflare path cannot vouch for a forwarded `CloudFront-*` family.
528
+
529
+ > [!IMPORTANT]
530
+ > Trackdown **reports** this trust state. It doesn't act on it — an unverified location is still returned in full. Whether an unverified location is good enough to ban an account, or only good enough to show a flag in the UI, is your application's call, not a gem's.
531
+
532
+ ### Putting it together
533
+
534
+ Act on the strength of the evidence, not just on the answer:
535
+
536
+ ```ruby
537
+ location = Trackdown.locate(request.remote_ip, request: request)
538
+
539
+ if location.available? && location.host_verified?
540
+ enforce_geoblock!(location.country_code) # evidence you'd be willing to defend
541
+ elsif location.available?
542
+ flag_for_review(location.country_code) # good enough to look at, not to act on
543
+ end
544
+ ```
545
+
546
+ And when you store it, store how you got it:
547
+
548
+ ```ruby
549
+ AbuseReport.create!(
550
+ ip: request.remote_ip,
551
+ **location.to_h(only: %i[
552
+ country_code city latitude longitude accuracy_radius_in_kilometers
553
+ provider_name provider_source source_trust resolved_at database_built_at
554
+ ])
555
+ )
556
+ ```
557
+
558
+ The names you can pass to `only:` are `Trackdown::LocationResult::FIELDS`. Three
559
+ ready-made slices come with it: `LOCATION_FIELDS` (where the IP is),
560
+ `PROVENANCE_FIELDS` (how we know), and `DEFAULT_FIELDS` (exactly what a
561
+ no-argument `to_h` returns, `country_info` included).
562
+
232
563
  ## Configuration
233
564
 
234
565
  ### Provider Options
235
566
 
236
567
  ```ruby
237
568
  Trackdown.configure do |config|
238
- # :auto - Try Cloudflare first, fall back to MaxMind (default, recommended)
569
+ # :auto - Use one IP-corroborated edge provider; fall back to MaxMind when none
570
+ # or both are valid (default, recommended for unambiguous deployments)
239
571
  # :cloudflare - Only use Cloudflare headers
572
+ # :cloudfront - Only use Amazon CloudFront headers
240
573
  # :maxmind - Only use MaxMind database
241
574
  config.provider = :auto
242
575
  end
@@ -262,6 +595,9 @@ Trackdown.configure do |config|
262
595
 
263
596
  # General
264
597
  config.reject_private_ips = true # Reject 192.168.x.x, 127.0.0.1, etc.
598
+
599
+ # Optional provider-specific source-trust callbacks are documented, with
600
+ # fail-closed examples, in "Did the request really come through your CDN?"
265
601
  end
266
602
  ```
267
603
 
@@ -292,11 +628,62 @@ When you enable "IP Geolocation" in Cloudflare, they add the `CF-IPCountry` head
292
628
  | `cf-postal-code` | `postal_code` |
293
629
  | `cf-timezone` | `timezone` |
294
630
 
295
- Trackdown reads these headers directly from the request with zero overhead no database lookups, no external API calls.
631
+ Trackdown reads these headers directly from the request—no database lookup or
632
+ external API call. It rejects non-finite or out-of-range coordinates using the
633
+ same WGS-84 bounds as the CloudFront provider. Exact Cloudflare field source:
634
+ https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers.
635
+ Exact coordinate bounds:
636
+ https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2.
637
+
638
+ Cloudflare's `XX` and `T1` values are not countries. Trackdown preserves `T1`
639
+ as useful Tor provenance but reports the location unavailable and renders the
640
+ white unknown flag rather than a malformed regional-indicator glyph. Unicode's
641
+ `ZZ` unknown/invalid territory is unavailable too. Non-string or malformed
642
+ optional values, and non-string, invalidly encoded, or malformed country values,
643
+ are ignored rather than allowed to raise from a lookup. Exact Cloudflare code contract:
644
+ https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-ipcountry.
645
+ Exact Unicode `ZZ` semantics:
646
+ https://www.unicode.org/reports/tr35/tr35-78/tr35.html#unicode_region_subtag_validity.
647
+
648
+ ### CloudFront Provider
649
+
650
+ When your app is behind Amazon CloudFront and an origin request policy adds the viewer-location headers, Trackdown maps the following values:
651
+
652
+ | CloudFront header | `trackdown` field |
653
+ |---|---|
654
+ | `CloudFront-Viewer-Country` | `country_code` |
655
+ | `CloudFront-Viewer-City` | `city` |
656
+ | `CloudFront-Viewer-Country-Region-Name` | `region` |
657
+ | `CloudFront-Viewer-Country-Region` | `region_code` |
658
+ | `CloudFront-Viewer-Latitude` | `latitude` |
659
+ | `CloudFront-Viewer-Longitude` | `longitude` |
660
+ | `CloudFront-Viewer-Metro-Code` | `metro_code` |
661
+ | `CloudFront-Viewer-Postal-Code` | `postal_code` |
662
+ | `CloudFront-Viewer-Time-Zone` | `timezone` |
663
+
664
+ Exact AWS source for every mapped header and its semantics:
665
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
666
+
667
+ Important details from that contract:
668
+
669
+ - `CloudFront-Viewer-Country` is validated against the `countries` catalog before CloudFront is considered available, with one deliberate exception: `XK` is preserved for Kosovo. Unicode CLDR documents `XK` as established industry practice and `ZZ` as unknown/invalid territory: https://www.unicode.org/reports/tr35/tr35-78/tr35.html#unicode_region_subtag_validity.
670
+ - Non-ASCII viewer-location values are RFC 3986 percent-encoded by CloudFront. Trackdown decodes them as UTF-8 without applying HTML form `+`-as-space behavior. RFC source: https://www.rfc-editor.org/rfc/rfc3986#section-2.1.
671
+ - Latitude and longitude are accepted only when finite and inside the WGS-84 bounds of `-90..90` and `-180..180`. Bounds source: https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2.
672
+ - City, metro code, and postal code may be unavailable. Extended headers are omitted for viewer IPs on the AWS network.
673
+ - CloudFront does not provide a continent header, so `continent` is derived from the validated country via the [`countries` gem](https://github.com/countries/countries) and normalized to the same two-letter code (`NA`, `EU`, …) returned by the other providers.
674
+
675
+ In `:auto`, Trackdown compares the target IP with `CloudFront-Viewer-Address`. A missing, malformed, or mismatching address causes that candidate to be skipped. If Cloudflare and CloudFront both appear valid, Trackdown refuses to guess, tries MaxMind, and otherwise returns `'Unknown'`. An explicitly configured `:cloudfront` provider reads valid CloudFront location headers without requiring the address comparison, which is useful only when the deployment's CloudFront trust boundary has already been secured.
676
+
677
+ The AWS managed policy includes every header in the table plus `CloudFront-Viewer-Address`, but it also forwards all viewer headers, cookies, and query strings:
678
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
679
+
680
+ Header values are trustworthy only when the origin rejects direct requests. Exact AWS sources:
681
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html
682
+ https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html
296
683
 
297
684
  ### MaxMind Provider
298
685
 
299
- Downloads the [GeoLite2-City](https://dev.maxmind.com/geoip/docs/databases/city-and-country/) database to your server and performs local lookups using connection pooling for performance. All fields (`country`, `city`, `region`, `continent`, `timezone`, `latitude`, `longitude`, `postal_code`, `metro_code`) are extracted from the database record.
686
+ Downloads the [GeoLite2-City](https://dev.maxmind.com/geoip/docs/databases/city-and-country/) database to your server and performs local lookups using connection pooling for performance. All fields (`country`, `city`, `region`, `continent`, `timezone`, `latitude`, `longitude`, `postal_code`, `metro_code`) are extracted from the database record, along with the record's [`accuracy_radius`](https://dev.maxmind.com/geoip/docs/databases/city-and-country/city-binary/) and the identity of the database that answered — see [How do you know?](#how-do-you-know).
300
687
 
301
688
 
302
689
  ## Docker & Container Deployments
@@ -355,13 +742,13 @@ end
355
742
 
356
743
  ### Background Jobs Consideration
357
744
 
358
- When using background job processors (Sidekiq, SolidQueue, GoodJob), geolocation lookups in jobs **cannot use Cloudflare headers** since there's no HTTP request. These jobs will fall back to MaxMind automatically when using `:auto` provider.
745
+ When using background job processors (Sidekiq, SolidQueue, GoodJob), geolocation lookups in jobs **cannot use Cloudflare or CloudFront headers** because there is no HTTP request. These jobs fall back to MaxMind automatically under `:auto`.
359
746
 
360
747
  Make sure MaxMind is properly configured if you're doing geolocation in background jobs:
361
748
 
362
749
  ```ruby
363
750
  # This works in controllers (has request)
364
- Trackdown.locate(ip, request: request) # Uses Cloudflare if available
751
+ Trackdown.locate(ip, request: request) # Uses one verified CDN provider if available
365
752
 
366
753
  # This works in background jobs (no request)
367
754
  Trackdown.locate(ip) # Falls back to MaxMind
@@ -22,17 +22,26 @@ module Trackdown
22
22
  def display_post_install_message
23
23
  say "\tThe `trackdown` gem has been successfully installed!", :green
24
24
  say "\nChoose your setup path:"
25
- say "\n Option 1: Cloudflare (Zero Config - Recommended)"
25
+ say "\n Option 1: Cloudflare (Header-Based)"
26
26
  say " 1. Ensure your app is behind Cloudflare"
27
27
  say " 2. Enable 'IP Geolocation' in Cloudflare dashboard (Network settings)"
28
28
  say " 3. Use: Trackdown.locate(request.remote_ip, request: request)"
29
- say " That's it! No API keys, no database needed."
30
- say "\n Option 2: MaxMind (BYOK)"
29
+ say " 4. Restrict direct-origin access before trusting CF-* headers"
30
+ say " Cloudflare origin security: https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/"
31
+ say " No API keys or database needed after CDN/origin setup."
32
+ say "\n Option 2: Amazon CloudFront (Header-Based)"
33
+ say " 1. Forward CloudFront viewer-location headers and CloudFront-Viewer-Address"
34
+ say " 2. Restrict direct-origin access before trusting CloudFront-* headers"
35
+ say " 3. Use: Trackdown.locate(request.remote_ip, request: request)"
36
+ say " AWS headers: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location"
37
+ say " AWS origin security: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html"
38
+ say "\n Option 3: MaxMind (BYOK)"
31
39
  say " 1. Configure your MaxMind credentials in `config/initializers/trackdown.rb`"
32
40
  say " 2. Run 'Trackdown.update_database' to download the database"
33
41
  say " 3. Schedule TrackdownDatabaseRefreshJob to run weekly"
34
- say "\n Option 3: Auto (Best of Both)"
35
- say " The default :auto mode tries Cloudflare first, falls back to MaxMind"
42
+ say "\n Option 4: Auto (Verified Edge + MaxMind Fallback)"
43
+ say " The default :auto mode uses one IP-corroborated CDN provider"
44
+ say " and falls back safely when no unique edge provider can be verified"
36
45
  say "\nEnjoy `trackdown`!", :green
37
46
  end
38
47