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.
data/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # 📍 `trackdown` - Ruby gem to geolocate IPs
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/trackdown.svg)](https://badge.fury.io/rb/trackdown) [![Build Status](https://github.com/rameerez/trackdown/workflows/Tests/badge.svg)](https://github.com/rameerez/trackdown/actions)
4
+
3
5
  > [!TIP]
4
6
  > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=trackdown)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=trackdown)!
5
7
 
6
8
  `trackdown` is a Ruby gem that allows you to geolocate IP addresses easily.
7
9
 
8
- 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.
9
11
 
10
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.
11
13
 
@@ -20,37 +22,60 @@ Given an IP, it gives you the corresponding:
20
22
  - 📺 Metro code (e.g. "807")
21
23
  - 🇺🇸 Emoji flag of the country
22
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
+
23
27
  ## First, choose your `trackdown` Geo IP provider
24
28
 
25
- ### Option 1: Cloudflare (recommended, zero config)
29
+ ### Option 1: Cloudflare (recommended for Cloudflare origins)
26
30
 
27
- 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:
28
32
  - No API keys needed
29
33
  - No database downloads
30
34
  - No external dependencies
31
35
  - Instant lookups from Cloudflare headers
32
36
 
33
- 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.
56
+
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.
34
59
 
35
- ### Option 2: MaxMind (BYOK - Bring Your Own Key)
60
+ ### Option 3: MaxMind (BYOK - Bring Your Own Key)
36
61
 
37
- For apps not behind Cloudflare, offline apps, non-Rails apps, or as a fallback, use MaxMind:
62
+ For apps not behind a supported CDN, offline apps, non-Rails apps, or as a fallback, use MaxMind:
38
63
  - Requires MaxMind account and license key
39
64
  - Requires downloading and maintaining a local database
40
65
  - Works offline once database is downloaded
41
66
  - Get started at [MaxMind](https://www.maxmind.com/)
42
67
 
43
- ### Option 3: Auto
68
+ ### Option 4: Auto
44
69
 
45
- 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'`.
46
71
 
47
72
  > [!NOTE]
48
- > 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.
49
74
 
50
75
 
51
76
  ## Installation
52
77
 
53
- Add this line to your application's Gemfile:
78
+ `trackdown` needs Ruby 3.1 or newer. Add this line to your application's Gemfile:
54
79
 
55
80
  ```ruby
56
81
  gem 'trackdown'
@@ -79,9 +104,40 @@ If your app is behind Cloudflare, setup is super simple:
79
104
  ```ruby
80
105
  # In your controller
81
106
  Trackdown.locate(request.remote_ip, request: request).country
82
- # => 'United States'
107
+ # => 'United States of America'
83
108
  ```
84
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'
136
+ ```
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
+
85
141
  ### Setup with MaxMind
86
142
 
87
143
  If you want to use `trackdown` with a MaxMind database as the geo IP data provider:
@@ -132,7 +188,7 @@ production:
132
188
  refresh_trackdown_database:
133
189
  class: TrackdownDatabaseRefreshJob
134
190
  queue: default
135
- schedule: every Saturday at 4am US/Pacific
191
+ schedule: every Saturday at 4am
136
192
  ```
137
193
 
138
194
  > [!NOTE]
@@ -140,15 +196,17 @@ production:
140
196
 
141
197
  ## Usage
142
198
 
143
- ### With Cloudflare (recommended when available)
199
+ ### With Cloudflare or CloudFront
144
200
 
145
201
  ```ruby
146
202
  # In your controller - pass the request object
147
203
  result = Trackdown.locate(request.remote_ip, request: request)
148
204
  result.country
149
- # => 'United States'
205
+ # => 'United States of America'
150
206
  ```
151
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
+
152
210
  ### With MaxMind or without request object
153
211
 
154
212
  To geolocate an IP address:
@@ -172,9 +230,9 @@ In fact, there are a few methods you can use:
172
230
 
173
231
  ```ruby
174
232
  result.country_code # => 'US'
175
- result.country_name # => 'United States'
233
+ result.country_name # => 'United States' (MaxMind's own name for it)
176
234
  result.country # => 'United States' (alias for country_name)
177
- result.city # => 'Mountain View' (from MaxMind or Cloudflare's "Add visitor location headers")
235
+ result.city # => 'Mountain View' (from MaxMind or configured CDN headers)
178
236
  result.region # => 'California'
179
237
  result.region_code # => 'CA'
180
238
  result.continent # => 'NA'
@@ -189,8 +247,21 @@ result.country_flag # => '🇺🇸' (alias for flag_emoji)
189
247
  result.country_info # => # Rich country data from the `countries` gem
190
248
  ```
191
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
+
192
263
  > [!NOTE]
193
- > 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'`).
194
265
 
195
266
  ### Rich country information
196
267
 
@@ -206,7 +277,8 @@ result.country_info.iso_long_name # => 'The United States of America'
206
277
 
207
278
  ### Hash data
208
279
 
209
- 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:
210
282
 
211
283
  ```ruby
212
284
  result.to_h
@@ -227,14 +299,277 @@ result.to_h
227
299
  # }
228
300
  ```
229
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
+
230
563
  ## Configuration
231
564
 
232
565
  ### Provider Options
233
566
 
234
567
  ```ruby
235
568
  Trackdown.configure do |config|
236
- # :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)
237
571
  # :cloudflare - Only use Cloudflare headers
572
+ # :cloudfront - Only use Amazon CloudFront headers
238
573
  # :maxmind - Only use MaxMind database
239
574
  config.provider = :auto
240
575
  end
@@ -260,6 +595,9 @@ Trackdown.configure do |config|
260
595
 
261
596
  # General
262
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?"
263
601
  end
264
602
  ```
265
603
 
@@ -290,12 +628,131 @@ When you enable "IP Geolocation" in Cloudflare, they add the `CF-IPCountry` head
290
628
  | `cf-postal-code` | `postal_code` |
291
629
  | `cf-timezone` | `timezone` |
292
630
 
293
- 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
294
683
 
295
684
  ### MaxMind Provider
296
685
 
297
- 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).
687
+
688
+
689
+ ## Docker & Container Deployments
690
+
691
+ When deploying with Docker, Kubernetes, or similar container orchestration, the MaxMind database file needs special handling since container filesystems are ephemeral.
692
+
693
+ ### Option 1: Persistent Volume (Recommended)
694
+
695
+ Mount a persistent volume for the database file so it survives container restarts and deployments.
298
696
 
697
+ **Kamal (`config/deploy.yml`):**
698
+ ```yaml
699
+ volumes:
700
+ - "trackdown_data:/rails/db/geodata"
701
+ ```
702
+
703
+ Then configure the database path:
704
+ ```ruby
705
+ # config/initializers/trackdown.rb
706
+ config.database_path = Rails.root.join('db', 'geodata', 'GeoLite2-City.mmdb').to_s
707
+ ```
708
+
709
+ **Docker Compose:**
710
+ ```yaml
711
+ services:
712
+ app:
713
+ volumes:
714
+ - trackdown_data:/rails/db/geodata
715
+
716
+ volumes:
717
+ trackdown_data:
718
+ ```
719
+
720
+ ### Option 2: Download on Container Start
721
+
722
+ If you prefer not to use volumes, download the database when the container starts. Add to your entrypoint or a post-deploy hook:
723
+
724
+ ```bash
725
+ # In your entrypoint.sh or deploy hook
726
+ bin/rails runner "Trackdown.update_database unless File.exist?(Trackdown.configuration.database_path)"
727
+ ```
728
+
729
+ Or create a job that runs on boot:
730
+
731
+ ```ruby
732
+ # config/initializers/trackdown_boot.rb
733
+ Rails.application.config.after_initialize do
734
+ if Rails.env.production? && !File.exist?(Trackdown.configuration.database_path)
735
+ Trackdown.update_database
736
+ end
737
+ end
738
+ ```
739
+
740
+ > [!WARNING]
741
+ > Option 2 adds startup time (~10-30 seconds) on fresh deploys and requires network access during boot. A persistent volume is more reliable for production.
742
+
743
+ ### Background Jobs Consideration
744
+
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`.
746
+
747
+ Make sure MaxMind is properly configured if you're doing geolocation in background jobs:
748
+
749
+ ```ruby
750
+ # This works in controllers (has request)
751
+ Trackdown.locate(ip, request: request) # Uses one verified CDN provider if available
752
+
753
+ # This works in background jobs (no request)
754
+ Trackdown.locate(ip) # Falls back to MaxMind
755
+ ```
299
756
 
300
757
  ## Development
301
758
 
@@ -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