trackdown 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 675d77c9715d2b1de76e4d385b95960b82f45355edb57689d2cf243197f19324
4
- data.tar.gz: 061e61803019b00dd99e2d186f3c3ccbe73cf5ee9a750b513e1dfc11c46c0859
3
+ metadata.gz: 78637df00207906a24e8553f69a16536a402d59af543c531613b9b3b881bad2c
4
+ data.tar.gz: 21c3fc9d6845db2322bfe85cd1a85d87539c16b211c12a3f3c9dc4de3bc4dd12
5
5
  SHA512:
6
- metadata.gz: 5394b49ff9c641ae941675b2bc18da7e92d934effab2f771707ded19179ea4f019a5c18533dc059e5e8f12e7cf6ff0847d0e3dd91586e1e218bb7f0f3d96c139
7
- data.tar.gz: bddbc1ea728846d08dd1ca75033c3a9b3eb95144fdbec641089593c8f1e177e3ac52eb9cb495b2a14bbc118d99d23a504f2ecbe37578017629f9344e13faf0d7
6
+ metadata.gz: '08cc2f24e4339a74690c74a9adfb687fb9db337c4215911f7673cf2c9583054e8325b8250c9ab219f7c878a80b8ba2287e238cf7776b61fe475830ef54203a07'
7
+ data.tar.gz: dfbff6a70b8d6ecbd8b18b191d0b6a819af2e72aad179e8ee9c007f8511af5a22a9531d80a698c87f33759f27ec48905aec23ffa6282b13b44ca1bb9cf021400
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.3.1] - 2026-02-24
2
+
3
+ - Fix incorrect Cloudflare geolocation when an upstream proxy sits before Cloudflare
4
+ - In `:auto` mode, compare `CF-Connecting-IP` with the target IP and fall back to MaxMind on mismatch
5
+ - Normalize compared IPs with `IPAddr` so equivalent IPv6 / IPv4-mapped forms are treated as matches
6
+ - Add tests covering matching and mismatching `CF-Connecting-IP` scenarios
7
+
1
8
  ## [0.3.0] - 2026-02-08
2
9
 
3
10
  - Add 8 new geolocation fields: `region`, `region_code`, `continent`, `timezone`, `latitude`, `longitude`, `postal_code`, `metro_code`
data/README.md CHANGED
@@ -1,5 +1,7 @@
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
 
@@ -132,7 +134,7 @@ production:
132
134
  refresh_trackdown_database:
133
135
  class: TrackdownDatabaseRefreshJob
134
136
  queue: default
135
- schedule: every Saturday at 4am US/Pacific
137
+ schedule: every Saturday at 4am
136
138
  ```
137
139
 
138
140
  > [!NOTE]
@@ -297,6 +299,74 @@ Trackdown reads these headers directly from the request with zero overhead — n
297
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.
298
300
 
299
301
 
302
+ ## Docker & Container Deployments
303
+
304
+ When deploying with Docker, Kubernetes, or similar container orchestration, the MaxMind database file needs special handling since container filesystems are ephemeral.
305
+
306
+ ### Option 1: Persistent Volume (Recommended)
307
+
308
+ Mount a persistent volume for the database file so it survives container restarts and deployments.
309
+
310
+ **Kamal (`config/deploy.yml`):**
311
+ ```yaml
312
+ volumes:
313
+ - "trackdown_data:/rails/db/geodata"
314
+ ```
315
+
316
+ Then configure the database path:
317
+ ```ruby
318
+ # config/initializers/trackdown.rb
319
+ config.database_path = Rails.root.join('db', 'geodata', 'GeoLite2-City.mmdb').to_s
320
+ ```
321
+
322
+ **Docker Compose:**
323
+ ```yaml
324
+ services:
325
+ app:
326
+ volumes:
327
+ - trackdown_data:/rails/db/geodata
328
+
329
+ volumes:
330
+ trackdown_data:
331
+ ```
332
+
333
+ ### Option 2: Download on Container Start
334
+
335
+ If you prefer not to use volumes, download the database when the container starts. Add to your entrypoint or a post-deploy hook:
336
+
337
+ ```bash
338
+ # In your entrypoint.sh or deploy hook
339
+ bin/rails runner "Trackdown.update_database unless File.exist?(Trackdown.configuration.database_path)"
340
+ ```
341
+
342
+ Or create a job that runs on boot:
343
+
344
+ ```ruby
345
+ # config/initializers/trackdown_boot.rb
346
+ Rails.application.config.after_initialize do
347
+ if Rails.env.production? && !File.exist?(Trackdown.configuration.database_path)
348
+ Trackdown.update_database
349
+ end
350
+ end
351
+ ```
352
+
353
+ > [!WARNING]
354
+ > 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.
355
+
356
+ ### Background Jobs Consideration
357
+
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.
359
+
360
+ Make sure MaxMind is properly configured if you're doing geolocation in background jobs:
361
+
362
+ ```ruby
363
+ # This works in controllers (has request)
364
+ Trackdown.locate(ip, request: request) # Uses Cloudflare if available
365
+
366
+ # This works in background jobs (no request)
367
+ Trackdown.locate(ip) # Falls back to MaxMind
368
+ ```
369
+
300
370
  ## Development
301
371
 
302
372
  After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake test` to run the Minitest tests.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ipaddr'
4
+
3
5
  require_relative 'base_provider'
4
6
  require_relative 'cloudflare_provider'
5
7
  require_relative 'maxmind_provider'
@@ -9,11 +11,19 @@ module Trackdown
9
11
  # Intelligent provider that automatically selects the best available provider
10
12
  # Priority order:
11
13
  # 1. Cloudflare (fastest, zero overhead, no external dependencies)
12
- # 2. MaxMind (fallback when Cloudflare not available)
14
+ # 2. MaxMind (fallback when Cloudflare not available or IP mismatch)
13
15
  #
14
16
  # This is the recommended default for most applications
17
+ #
18
+ # IMPORTANT: When there's an upstream proxy before Cloudflare (e.g., a legacy
19
+ # API gateway), Cloudflare's geo headers will reflect the proxy's location,
20
+ # not the real client. AutoProvider detects this by comparing CF-Connecting-IP
21
+ # with the passed IP and falls back to MaxMind when they don't match.
15
22
  class AutoProvider < BaseProvider
23
+ CF_CONNECTING_IP_HEADER = 'HTTP_CF_CONNECTING_IP'
24
+
16
25
  @@warned_no_providers = false
26
+ @@warned_ip_mismatch = false
17
27
  @@warn_mutex = Mutex.new
18
28
 
19
29
  class << self
@@ -29,8 +39,16 @@ module Trackdown
29
39
  # @return [LocationResult] The location information
30
40
  def locate(ip, request: nil)
31
41
  # Try Cloudflare first - it's instant and free!
42
+ # But only if the IP matches what Cloudflare geolocated
32
43
  if CloudflareProvider.available?(request: request)
33
- return CloudflareProvider.locate(ip, request: request)
44
+ if cloudflare_ip_matches?(ip, request)
45
+ return CloudflareProvider.locate(ip, request: request)
46
+ else
47
+ # IP mismatch: there's likely an upstream proxy before Cloudflare
48
+ # Cloudflare's geo headers are based on the proxy IP, not the real client
49
+ # Fall back to MaxMind with the correct IP
50
+ warn_ip_mismatch(ip, request)
51
+ end
34
52
  end
35
53
 
36
54
  # Fall back to MaxMind if available
@@ -45,6 +63,53 @@ module Trackdown
45
63
 
46
64
  private
47
65
 
66
+ # Check if the IP we want to geolocate matches what Cloudflare saw as the client
67
+ # If they don't match, there's an upstream proxy and Cloudflare's geo headers are wrong
68
+ def cloudflare_ip_matches?(ip, request)
69
+ return true unless request # No request means we can't check
70
+
71
+ cf_connecting_ip = request.env[CF_CONNECTING_IP_HEADER]
72
+ return true if cf_connecting_ip.nil? || cf_connecting_ip.empty?
73
+
74
+ # Normalize IPs for comparison (handle IPv6 formatting differences)
75
+ normalize_ip(ip) == normalize_ip(cf_connecting_ip)
76
+ end
77
+
78
+ def normalize_ip(ip)
79
+ return nil if ip.nil?
80
+
81
+ value = ip.to_s.strip
82
+ return nil if value.empty?
83
+
84
+ parsed_ip = IPAddr.new(value)
85
+ parsed_ip = parsed_ip.native if parsed_ip.ipv4_mapped?
86
+ parsed_ip.to_s.downcase
87
+ rescue IPAddr::InvalidAddressError
88
+ # If parsing fails, fall back to string comparison so we still have
89
+ # deterministic behavior and can trigger MaxMind fallback on mismatch.
90
+ value.downcase
91
+ end
92
+
93
+ def warn_ip_mismatch(ip, request)
94
+ return if @@warned_ip_mismatch
95
+
96
+ @@warn_mutex.synchronize do
97
+ return if @@warned_ip_mismatch
98
+ @@warned_ip_mismatch = true
99
+
100
+ cf_ip = request&.env&.dig(CF_CONNECTING_IP_HEADER)
101
+ message = "[Trackdown] IP mismatch detected: request IP (#{ip}) differs from " \
102
+ "CF-Connecting-IP (#{cf_ip}). This usually means there's an upstream " \
103
+ "proxy before Cloudflare. Falling back to MaxMind for accurate geolocation."
104
+
105
+ if defined?(Rails)
106
+ Rails.logger.info(message)
107
+ else
108
+ warn(message)
109
+ end
110
+ end
111
+ end
112
+
48
113
  def warn_no_providers
49
114
  # Only warn once per process to avoid log spam
50
115
  return if @@warned_no_providers
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trackdown
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trackdown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-08 00:00:00.000000000 Z
10
+ date: 2026-02-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: countries