trackdown 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +478 -21
- data/lib/generators/trackdown/install_generator.rb +14 -5
- data/lib/generators/trackdown/templates/trackdown.rb +87 -4
- data/lib/trackdown/configuration.rb +112 -6
- data/lib/trackdown/database_fingerprint.rb +102 -0
- data/lib/trackdown/database_updater.rb +89 -28
- data/lib/trackdown/ip_locator.rb +4 -1
- data/lib/trackdown/location_result.rb +223 -18
- data/lib/trackdown/providers/auto_provider.rb +251 -20
- data/lib/trackdown/providers/base_provider.rb +71 -5
- data/lib/trackdown/providers/cloudflare_provider.rb +57 -20
- data/lib/trackdown/providers/cloudfront_provider.rb +197 -0
- data/lib/trackdown/providers/maxmind_provider.rb +113 -16
- data/lib/trackdown/version.rb +1 -1
- data/lib/trackdown.rb +3 -1
- metadata +12 -16
- data/.simplecov +0 -25
- data/AGENTS.md +0 -5
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -12
- data/sig/trackdown.rbs +0 -4
|
@@ -7,9 +7,10 @@ Trackdown.configure do |config|
|
|
|
7
7
|
# Choose your IP geolocation provider:
|
|
8
8
|
#
|
|
9
9
|
# :auto (recommended, default)
|
|
10
|
-
# -
|
|
11
|
-
# -
|
|
12
|
-
# -
|
|
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
|
-
#
|
|
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
|
-
|
|
14
|
-
|
|
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 -
|
|
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 = [
|
|
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 #
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
71
|
+
'Authentication failed. Please check your MaxMind account ID and license key.'
|
|
44
72
|
when /403/
|
|
45
|
-
|
|
73
|
+
'Access forbidden. Your MaxMind license may not have access to this database.'
|
|
46
74
|
else
|
|
47
|
-
"HTTP Error: #{
|
|
75
|
+
"HTTP Error: #{error.message}"
|
|
48
76
|
end
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
data/lib/trackdown/ip_locator.rb
CHANGED
|
@@ -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 [
|
|
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
|