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.
@@ -7,9 +7,10 @@ Trackdown.configure do |config|
7
7
  # Choose your IP geolocation provider:
8
8
  #
9
9
  # :auto (recommended, default)
10
- # - Tries Cloudflare first (instant, zero overhead)
11
- # - Falls back to MaxMind if Cloudflare not available
12
- # - Perfect for hybrid deployments
10
+ # - Verifies CDN geolocation against the CDN's client-IP header
11
+ # - Supports Cloudflare and Amazon CloudFront
12
+ # - Falls back to MaxMind when neither CDN can be verified
13
+ # - Tries MaxMind, then Unknown, when both CDN candidates appear valid
13
14
  #
14
15
  # :cloudflare
15
16
  # - Uses Cloudflare CF-IPCountry header
@@ -17,6 +18,14 @@ Trackdown.configure do |config|
17
18
  # - Zero additional dependencies!
18
19
  # - Must pass request object: Trackdown.locate(ip, request: request)
19
20
  #
21
+ # :cloudfront
22
+ # - Uses Amazon CloudFront CloudFront-Viewer-* headers
23
+ # - Requires: a CloudFront origin request policy forwarding location headers
24
+ # - Requires: direct-origin access blocked before headers can be trusted
25
+ # - Must pass request object: Trackdown.locate(ip, request: request)
26
+ # - Exact AWS header contract:
27
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
28
+ #
20
29
  # :maxmind
21
30
  # - Uses MaxMind GeoLite2 database
22
31
  # - Requires: maxmind-db and connection_pool gems
@@ -31,8 +40,29 @@ Trackdown.configure do |config|
31
40
  # 2. In Cloudflare dashboard → Network → Enable "IP Geolocation"
32
41
  # OR under Rules → Transform Rules → Managed Transforms → Enable "Add visitor location headers"
33
42
  # 3. Use: Trackdown.locate(request.remote_ip, request: request)
43
+ # 4. Restrict direct-origin traffic before trusting CF-* headers:
44
+ # https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/#block-other-ip-addresses-recommended
45
+ # https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/
46
+ #
47
+ # No gems, API keys, or database are needed after the CDN/origin setup.
48
+
49
+ # ========================================
50
+ # Amazon CloudFront Setup (for :cloudfront or :auto providers)
51
+ # ========================================
52
+ # 1. Attach an origin request policy containing CloudFront's viewer-location
53
+ # headers and CloudFront-Viewer-Address. Exact AWS policy documentation:
54
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html
55
+ # 2. Restrict the origin so direct clients cannot forge CloudFront-* headers.
56
+ # AWS's exact custom-origin guidance:
57
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html
58
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html
59
+ # 3. Use: Trackdown.locate(request.remote_ip, request: request)
34
60
  #
35
- # That's it! No gems, no API keys, no database needed.
61
+ # AWS's AllViewerAndCloudFrontHeaders-2022-06 managed policy includes the
62
+ # required headers, but also forwards every viewer header, cookie, and query:
63
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
64
+ # Prefer a custom least-privilege policy, or explicitly select :cloudfront for
65
+ # a secured deployment where both CDN header families intentionally coexist.
36
66
 
37
67
  # ========================================
38
68
  # MaxMind Setup (for :maxmind or :auto providers)
@@ -66,6 +96,59 @@ Trackdown.configure do |config|
66
96
  # config.pool_timeout = 3 # Pool wait timeout (seconds)
67
97
  # config.memory_mode = MaxMind::DB::MODE_MEMORY # or MODE_FILE to reduce memory
68
98
 
99
+ # ========================================
100
+ # Trusted CDN Path Verification (optional, provider-specific)
101
+ # ========================================
102
+ # CDN geolocation headers are just headers: anyone who can reach an
103
+ # unprotected origin directly can send you a convincing set of them. Trackdown
104
+ # therefore marks every request-backed result :unverified unless you tell it
105
+ # how *you* know the request really came through that specific CDN. Keep these
106
+ # checks separate: a trusted CloudFront request must not authenticate CF-*
107
+ # headers that CloudFront forwarded from a viewer, and vice versa.
108
+ #
109
+ # Cloudflare example: have the ingress/middleware that actually checked
110
+ # Authenticated Origin Pulls or the Cloudflare peer network set this private
111
+ # Rack-environment flag. A viewer must never be able to set it:
112
+ #
113
+ # config.verify_request_came_through_trusted_cloudflare_path_with do |request|
114
+ # request.env['my_app.cloudflare_origin_was_verified'] == true
115
+ # end
116
+ #
117
+ # CloudFront example: require the origin-only custom header configured on the
118
+ # distribution. Refuse to boot if the expected secret is absent, and require
119
+ # a non-empty supplied value before comparing:
120
+ #
121
+ # expected_cloudfront_origin_secret =
122
+ # Rails.application.credentials.dig(:cloudfront, :origin_secret).to_s
123
+ # raise 'Missing CloudFront origin secret' if expected_cloudfront_origin_secret.empty?
124
+ #
125
+ # config.verify_request_came_through_trusted_cloudfront_path_with do |request|
126
+ # supplied_cloudfront_origin_secret =
127
+ # request.env['HTTP_X_CLOUDFRONT_ORIGIN_SECRET'].to_s
128
+ #
129
+ # !supplied_cloudfront_origin_secret.empty? &&
130
+ # ActiveSupport::SecurityUtils.secure_compare(
131
+ # supplied_cloudfront_origin_secret,
132
+ # expected_cloudfront_origin_secret
133
+ # )
134
+ # end
135
+ #
136
+ # Both empty checks matter: Rails secure_compare('', '') is true because the
137
+ # implementation checks equal byte lengths and then compares the bytes:
138
+ # https://api.rubyonrails.org/classes/ActiveSupport/SecurityUtils.html#method-c-secure_compare
139
+ #
140
+ # Results then report `source_trust` as :host_verified instead of :unverified,
141
+ # and `source_was_verified_by_host?` becomes true. Trackdown reports this; it
142
+ # does not act on it. Deciding what an unverified location may be used for is
143
+ # your application's call.
144
+ #
145
+ # https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/
146
+ # https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/#block-other-ip-addresses-recommended
147
+ # https://developers.cloudflare.com/fundamentals/reference/http-headers/#request-headers
148
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html
149
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html
150
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
151
+
69
152
  # ========================================
70
153
  # General Options
71
154
  # ========================================
@@ -9,19 +9,22 @@ rescue LoadError
9
9
  end
10
10
 
11
11
  module Trackdown
12
+ # Runtime choices for providers, MaxMind, and provider-specific source trust.
12
13
  class Configuration
13
- attr_accessor :provider, :maxmind_license_key, :maxmind_account_id, :database_path,
14
- :timeout, :pool_size, :pool_timeout, :memory_mode,
15
- :reject_private_ips
14
+ attr_reader :provider
15
+ attr_accessor :maxmind_license_key, :maxmind_account_id, :database_path,
16
+ :timeout, :pool_size, :pool_timeout, :memory_mode, :reject_private_ips
16
17
 
17
18
  # Available provider types:
18
- # :auto - Try Cloudflare first, fall back to MaxMind (recommended)
19
+ # :auto - Use one IP-corroborated CDN provider, otherwise fall back to MaxMind (recommended)
19
20
  # :cloudflare - Only use Cloudflare headers
21
+ # :cloudfront - Only use Amazon CloudFront headers
20
22
  # :maxmind - Only use MaxMind database
21
- VALID_PROVIDERS = [:auto, :cloudflare, :maxmind].freeze
23
+ VALID_PROVIDERS = %i[auto cloudflare cloudfront maxmind].freeze
24
+ TRUSTED_CDN_PROVIDERS = %i[cloudflare cloudfront].freeze
22
25
 
23
26
  def initialize
24
- @provider = :auto # Intelligent default: try Cloudflare first, fall back to MaxMind
27
+ @provider = :auto # Safe default: use one verified edge candidate, otherwise MaxMind
25
28
  @maxmind_license_key = nil
26
29
  @maxmind_account_id = nil
27
30
  @database_path = defined?(Rails) ? Rails.root.join('db', 'GeoLite2-City.mmdb').to_s : 'db/GeoLite2-City.mmdb'
@@ -30,17 +33,120 @@ module Trackdown
30
33
  @pool_timeout = 3 # seconds
31
34
  @memory_mode = MAXMIND_AVAILABLE ? MaxMind::DB::MODE_MEMORY : nil
32
35
  @reject_private_ips = true
36
+ @trusted_cdn_path_verifiers = {}
37
+ @warned_verifier_raised = {}
38
+ @verifier_mutex = Mutex.new
33
39
  end
34
40
 
35
41
  def provider=(value)
36
42
  unless VALID_PROVIDERS.include?(value)
37
43
  raise ArgumentError, "Invalid provider: #{value}. Must be one of: #{VALID_PROVIDERS.join(', ')}"
38
44
  end
45
+
39
46
  @provider = value
40
47
  end
41
48
 
42
49
  def reject_private_ips?
43
50
  @reject_private_ips
44
51
  end
52
+
53
+ # Tell Trackdown how *you* know a request really came through Cloudflare, so
54
+ # only Cloudflare results can say `source_trust: :host_verified`:
55
+ #
56
+ # expected = Rails.application.credentials.cloudflare_origin_secret.to_s
57
+ # raise 'Missing Cloudflare origin secret' if expected.empty?
58
+ #
59
+ # config.verify_request_came_through_trusted_cloudflare_path_with do |request|
60
+ # supplied = request.env['HTTP_X_ORIGIN_SECRET'].to_s
61
+ # !supplied.empty? && ActiveSupport::SecurityUtils.secure_compare(supplied, expected)
62
+ # end
63
+ #
64
+ # The non-empty checks are essential: secure_compare('', '') is true. Rails:
65
+ # https://api.rubyonrails.org/classes/ActiveSupport/SecurityUtils.html#method-c-secure_compare
66
+ #
67
+ # Trackdown never infers trust from headers. Anyone who can reach an
68
+ # unprotected origin can set them. Verify each CDN independently so a trusted
69
+ # CloudFront path can never vouch for forwarded, viewer-supplied CF-* headers:
70
+ # https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/
71
+ # https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/#block-other-ip-addresses-recommended
72
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html
73
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html
74
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
75
+ #
76
+ # Trackdown reports this trust state; it does not act on it. Deciding what an
77
+ # unverified location may be used for is your application's call.
78
+ def verify_request_came_through_trusted_cloudflare_path_with(verifier = nil, &block)
79
+ verify_request_came_through_trusted_cdn_path_with(:cloudflare, verifier, &block)
80
+ end
81
+
82
+ def verify_request_came_through_trusted_cloudfront_path_with(verifier = nil, &block)
83
+ verify_request_came_through_trusted_cdn_path_with(:cloudfront, verifier, &block)
84
+ end
85
+
86
+ # Provider-aware lower-level form used by the two plain-English helpers above.
87
+ def verify_request_came_through_trusted_cdn_path_with(provider_name, verifier = nil, &block)
88
+ validate_trusted_cdn_provider!(provider_name)
89
+ verifier ||= block
90
+
91
+ if verifier.nil?
92
+ raise ArgumentError, "verify_request_came_through_trusted_#{provider_name}_path_with needs a block or " \
93
+ 'a callable saying how you know a request came through that CDN'
94
+ end
95
+
96
+ unless verifier.respond_to?(:call)
97
+ raise ArgumentError, "The trusted #{provider_name} path verifier must respond to #call " \
98
+ "(a block, proc, lambda, or any callable object), got: #{verifier.inspect}"
99
+ end
100
+
101
+ @verifier_mutex.synchronize do
102
+ @warned_verifier_raised.delete(provider_name)
103
+ @trusted_cdn_path_verifiers[provider_name] = verifier
104
+ end
105
+ end
106
+
107
+ # Did the host vouch for this request? Asked fresh every time, never cached,
108
+ # and a verifier that blows up means "no" — a geolocation lookup must not be
109
+ # able to take an application down.
110
+ def request_came_through_trusted_cdn_path?(request, provider_name:)
111
+ validate_trusted_cdn_provider!(provider_name)
112
+ verifier = trusted_cdn_path_verifier_for(provider_name)
113
+ return false unless request && verifier
114
+
115
+ begin
116
+ !!verifier.call(request)
117
+ rescue StandardError => e
118
+ warn_verifier_raised(provider_name, e)
119
+ false
120
+ end
121
+ end
122
+
123
+ def trusted_cdn_path_verifier_for(provider_name)
124
+ validate_trusted_cdn_provider!(provider_name)
125
+ @verifier_mutex.synchronize { @trusted_cdn_path_verifiers[provider_name] }
126
+ end
127
+
128
+ private
129
+
130
+ def validate_trusted_cdn_provider!(provider_name)
131
+ return if TRUSTED_CDN_PROVIDERS.include?(provider_name)
132
+
133
+ raise ArgumentError, "Invalid trusted CDN provider: #{provider_name.inspect}. " \
134
+ "Must be one of: #{TRUSTED_CDN_PROVIDERS.join(', ')}"
135
+ end
136
+
137
+ def warn_verifier_raised(provider_name, error)
138
+ should_warn = @verifier_mutex.synchronize do
139
+ next false if @warned_verifier_raised[provider_name]
140
+
141
+ @warned_verifier_raised[provider_name] = true
142
+ true
143
+ end
144
+ return unless should_warn
145
+
146
+ message = "[Trackdown] Your trusted #{provider_name} path verifier raised " \
147
+ "#{error.class}: #{error.message}. Treating the #{provider_name} request source as :unverified."
148
+
149
+ defined?(Rails) ? Rails.logger.error(message) : warn(message)
150
+ end
45
151
  end
46
152
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Trackdown
6
+ # The identity of the MaxMind database file that answered a lookup.
7
+ #
8
+ # `build_epoch` comes straight from the database's own metadata, so it costs
9
+ # nothing. The SHA-256 digest costs a full read of the database file, so it is
10
+ # computed the first time somebody asks for it and then reused by every
11
+ # reader bound to that exact file generation — never once per lookup.
12
+ #
13
+ # If the file changes underneath us the digest becomes `nil` rather than a
14
+ # number that describes a file we are no longer reading.
15
+ #
16
+ # MaxMind documents the exact build_epoch metadata field here:
17
+ # https://maxmind.github.io/MaxMind-DB/#build_epoch
18
+ class DatabaseFingerprint
19
+ READ_CHUNK_BYTES = 1 << 20 # 1 MiB
20
+ CAPTURE_CURRENT_FILE_IDENTITY = Object.new.freeze
21
+ private_constant :CAPTURE_CURRENT_FILE_IDENTITY
22
+
23
+ attr_reader :path, :build_epoch
24
+
25
+ def initialize(path:, build_epoch: nil, captured_file_identity: CAPTURE_CURRENT_FILE_IDENTITY)
26
+ @path = path.to_s.dup.freeze
27
+ @build_epoch = build_epoch
28
+ @identity = if captured_file_identity.equal?(CAPTURE_CURRENT_FILE_IDENTITY)
29
+ current_file_identity
30
+ else
31
+ captured_file_identity
32
+ end
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ # Attach the metadata read by a database reader without losing the file
37
+ # identity captured *before* that reader opened the path. This is what keeps
38
+ # a reader that still has database A in memory from ever digesting database B
39
+ # after the path is replaced.
40
+ def with_build_epoch(build_epoch)
41
+ self.class.new(path: @path, build_epoch: build_epoch, captured_file_identity: @identity)
42
+ end
43
+
44
+ # Stable inside one process and suitable for sharing one lazy digest between
45
+ # all pooled readers that opened the same database generation.
46
+ def cache_key
47
+ [@path, @build_epoch, @identity].freeze
48
+ end
49
+
50
+ # When MaxMind built this database.
51
+ def built_at
52
+ Time.at(@build_epoch).utc if @build_epoch.is_a?(Numeric)
53
+ end
54
+
55
+ # Has the file been replaced since we fingerprinted it?
56
+ def changed?
57
+ current_file_identity != @identity
58
+ end
59
+
60
+ # The digest of the database we read, or nil if we can't honestly compute one.
61
+ def sha256
62
+ return @sha256 if defined?(@sha256)
63
+
64
+ @mutex.synchronize do
65
+ @sha256 = compute_sha256 unless defined?(@sha256)
66
+ end
67
+
68
+ @sha256
69
+ end
70
+
71
+ private
72
+
73
+ # Read the file whole, but hold only a chunk of it in memory at a time, and
74
+ # confirm on both sides of the read that we digested a single stable file.
75
+ def compute_sha256
76
+ return nil if @identity.nil? || changed?
77
+
78
+ digest = Digest::SHA256.new
79
+ File.open(@path, 'rb') do |file|
80
+ return nil unless identity_of(file.stat) == @identity
81
+
82
+ digest << file.read(READ_CHUNK_BYTES) until file.eof?
83
+
84
+ return nil unless identity_of(file.stat) == @identity
85
+ end
86
+
87
+ changed? ? nil : digest.hexdigest
88
+ rescue SystemCallError, IOError
89
+ nil
90
+ end
91
+
92
+ def current_file_identity
93
+ identity_of(File.stat(@path))
94
+ rescue SystemCallError
95
+ nil
96
+ end
97
+
98
+ def identity_of(stat)
99
+ [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime].freeze
100
+ end
101
+ end
102
+ end
@@ -1,15 +1,44 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'open-uri'
4
+ require 'fileutils'
5
+ require 'tempfile'
2
6
  require 'zlib'
3
7
  require 'rubygems/package'
4
8
 
5
9
  module Trackdown
10
+ # Downloads and safely installs the configured MaxMind GeoLite2 City database.
6
11
  class DatabaseUpdater
7
- DOWNLOAD_URL = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%{license_key}&suffix=tar.gz"
12
+ DOWNLOAD_URL = 'https://download.maxmind.com/app/geoip_download?' \
13
+ 'edition_id=GeoLite2-City&license_key=%<license_key>s&suffix=tar.gz'
8
14
 
9
15
  class << self
10
16
  def update
11
- download_url = DOWNLOAD_URL % { license_key: Trackdown.configuration.maxmind_license_key }
17
+ download_database { |remote_file| install_download(remote_file) }
18
+
19
+ # Serve the database we just downloaded, not the one already open in memory.
20
+ Providers::MaxmindProvider.reset_database!
12
21
 
22
+ Rails.logger.info('MaxMind database updated successfully') if defined?(Rails)
23
+ true
24
+ rescue OpenURI::HTTPError => e
25
+ message = http_error_message(e)
26
+ Rails.logger.error("Error updating MaxMind database: #{message}") if defined?(Rails)
27
+ raise Error, message
28
+ rescue Error
29
+ raise
30
+ rescue StandardError => e
31
+ Rails.logger.error("Error updating MaxMind database: #{e.message}") if defined?(Rails)
32
+ raise Error, "Failed to update database: #{e.message}"
33
+ end
34
+
35
+ private
36
+
37
+ def download_database(&block)
38
+ download_url = format(
39
+ DOWNLOAD_URL,
40
+ license_key: Trackdown.configuration.maxmind_license_key
41
+ )
13
42
  options = {
14
43
  http_basic_authentication: [
15
44
  Trackdown.configuration.maxmind_account_id.to_s,
@@ -18,39 +47,71 @@ module Trackdown
18
47
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
19
48
  }
20
49
 
21
- URI.open(download_url, **options) do |remote_file|
22
- Zlib::GzipReader.wrap(remote_file) do |gz|
23
- Gem::Package::TarReader.new(gz) do |tar|
24
- tar.each do |entry|
25
- if entry.full_name.end_with?('.mmdb')
26
- FileUtils.mkdir_p(File.dirname(Trackdown.configuration.database_path))
27
-
28
- File.open(Trackdown.configuration.database_path, 'wb') do |file|
29
- file.write(entry.read)
30
- end
31
- break
32
- end
33
- end
34
- end
50
+ URI.parse(download_url).open(**options, &block)
51
+ end
52
+
53
+ def install_download(remote_file)
54
+ Zlib::GzipReader.wrap(remote_file) do |gzip_reader|
55
+ Gem::Package::TarReader.new(gzip_reader) do |tar_reader|
56
+ entry = tar_reader.find { |candidate| database_entry?(candidate) }
57
+ raise Error, 'The downloaded MaxMind archive did not contain a .mmdb database' unless entry
58
+
59
+ install_database(entry)
35
60
  end
36
61
  end
62
+ end
37
63
 
38
- Rails.logger.info("MaxMind database updated successfully") if defined?(Rails)
39
- true
40
- rescue OpenURI::HTTPError => e
41
- message = case e.message
64
+ def database_entry?(entry)
65
+ entry.file? && entry.full_name.end_with?('.mmdb')
66
+ end
67
+
68
+ def http_error_message(error)
69
+ case error.message
42
70
  when /401/
43
- "Authentication failed. Please check your MaxMind account ID and license key."
71
+ 'Authentication failed. Please check your MaxMind account ID and license key.'
44
72
  when /403/
45
- "Access forbidden. Your MaxMind license may not have access to this database."
73
+ 'Access forbidden. Your MaxMind license may not have access to this database.'
46
74
  else
47
- "HTTP Error: #{e.message}"
75
+ "HTTP Error: #{error.message}"
48
76
  end
49
- Rails.logger.error("Error updating MaxMind database: #{message}") if defined?(Rails)
50
- raise Error, message
51
- rescue => e
52
- Rails.logger.error("Error updating MaxMind database: #{e.message}") if defined?(Rails)
53
- raise Error, "Failed to update database: #{e.message}"
77
+ end
78
+
79
+ # Write beside the destination, flush the complete database, and only then
80
+ # replace the path. Existing MODE_FILE readers keep their already-open file
81
+ # instead of observing a truncate-and-rewrite in progress.
82
+ # Ruby File.rename contract:
83
+ # https://docs.ruby-lang.org/en/3.3/File.html#method-c-rename
84
+ # maxmind-db MODE_FILE reader:
85
+ # https://github.com/maxmind/MaxMind-DB-Reader-ruby/blob/v1.2.0/lib/maxmind/db/file_reader.rb#L36-L55
86
+ def install_database(entry)
87
+ destination = Trackdown.configuration.database_path
88
+ directory = File.dirname(destination)
89
+ FileUtils.mkdir_p(directory)
90
+
91
+ Tempfile.create(['trackdown-', '.mmdb'], directory) do |temporary_file|
92
+ temporary_file.binmode
93
+ copy_database(entry, temporary_file)
94
+ temporary_file.flush
95
+ temporary_file.fsync
96
+ File.chmod(database_permissions(destination), temporary_file.path)
97
+ temporary_file.close
98
+ File.rename(temporary_file.path, destination)
99
+ end
100
+ end
101
+
102
+ def copy_database(entry, destination)
103
+ until entry.eof?
104
+ chunk = entry.read(1 << 20)
105
+ break if chunk.nil? || chunk.empty?
106
+
107
+ destination.write(chunk)
108
+ end
109
+ end
110
+
111
+ def database_permissions(destination)
112
+ File.stat(destination).mode & 0o777
113
+ rescue SystemCallError
114
+ 0o644
54
115
  end
55
116
  end
56
117
  end
@@ -4,6 +4,7 @@ require_relative 'location_result'
4
4
  require_relative 'ip_validator'
5
5
  require_relative 'providers/auto_provider'
6
6
  require_relative 'providers/cloudflare_provider'
7
+ require_relative 'providers/cloudfront_provider'
7
8
  require_relative 'providers/maxmind_provider'
8
9
 
9
10
  module Trackdown
@@ -11,7 +12,7 @@ module Trackdown
11
12
  class << self
12
13
  # Locate an IP address using the configured provider
13
14
  # @param ip [String] The IP address to locate
14
- # @param request [ActionDispatch::Request, nil] Optional Rails request object for Cloudflare provider
15
+ # @param request [#env, nil] Optional Rack-compatible request object for CDN providers
15
16
  # @return [LocationResult] The location information
16
17
  def locate(ip, request: nil)
17
18
  IpValidator.validate!(ip)
@@ -32,6 +33,8 @@ module Trackdown
32
33
  Providers::AutoProvider
33
34
  when :cloudflare
34
35
  Providers::CloudflareProvider
36
+ when :cloudfront
37
+ Providers::CloudfrontProvider
35
38
  when :maxmind
36
39
  Providers::MaxmindProvider
37
40
  else