Class: Otto::Privacy::GeoResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/otto/privacy/geo_resolver.rb

Overview

Lightweight geo-location resolution for IP addresses

Provides country-level geo-location without requiring external databases or API calls. Supports headers from major CDN/infrastructure providers (Cloudflare, AWS CloudFront, Fastly, Akamai, Azure) with fallback to basic IP range detection.

Supported CDN/Infrastructure Headers:

  • Cloudflare: CF-IPCountry

  • AWS CloudFront: CloudFront-Viewer-Country

  • Fastly: Fastly-Client-IP-Country

  • Akamai: X-Akamai-Edgescape (country_code=XX format)

  • Azure Front Door: X-Azure-ClientIP-Country

  • Semi-standard: X-Geo-Country, X-Country-Code, Country-Code

Resolution flow

Request → Has familiar HTTP Header?
          ├─ Yes → Return country (Cloudflare, AWS, etc.)
          └─ No → Custom Resolver?
                  ├─ Configured → Call & validate
                  │               ├─ Valid → Return country
                  │               └─ Invalid/Error → Continue
                  └─ Not configured → Built-in range detection
                                      └─ Unknown ('**')

Examples:

Resolve country from Cloudflare header

env = { 'HTTP_CF_IPCOUNTRY' => 'US' }
GeoResolver.resolve('1.2.3.4', env)
# => 'US'

Resolve from AWS CloudFront

env = { 'HTTP_CLOUDFRONT_VIEWER_COUNTRY' => 'GB' }
GeoResolver.resolve('1.2.3.4', env)
# => 'GB'

Resolve without CDN headers

GeoResolver.resolve('8.8.8.8', {})
# => 'US' (Google DNS via range detection)

Using a custom resolver (MaxMind)

GeoResolver.custom_resolver = ->(ip, env) {
  reader = MaxMind::DB.new('GeoLite2-Country.mmdb')
  result = reader.get(ip)
  result&.dig('country', 'iso_code')
}
GeoResolver.resolve('1.2.3.4', {})  # Uses custom resolver

Extending via subclass

class MyGeoResolver < Otto::Privacy::GeoResolver
  def self.detect_by_range(ip)
    # Custom logic here
    super  # Fall back to parent
  end
end

Constant Summary collapse

UNKNOWN =

Unknown country code (not ISO 3166-1 alpha-2, intentionally distinct)

'**'
KNOWN_RANGES =

Known IP ranges for major providers (limited set for basic detection) For comprehensive geo-location, use CDN headers or custom resolver

{
  # Google Public DNS
  IPAddr.new('8.8.8.0/24') => 'US',
  IPAddr.new('8.8.4.0/24') => 'US',

  # Cloudflare DNS
  IPAddr.new('1.1.1.0/24') => 'US',
  IPAddr.new('1.0.0.0/24') => 'US',

  # AWS US-East
  IPAddr.new('52.0.0.0/11') => 'US',
  IPAddr.new('54.0.0.0/8') => 'US',

  # AWS EU-West
  IPAddr.new('34.240.0.0/13') => 'IE',
  IPAddr.new('52.16.0.0/14') => 'IE',

  # AWS AP-Southeast
  IPAddr.new('13.210.0.0/15') => 'AU',
  IPAddr.new('52.62.0.0/15') => 'AU',

  # Quad9 DNS (Switzerland)
  IPAddr.new('9.9.9.0/24') => 'CH',

  # OpenDNS
  IPAddr.new('208.67.222.0/24') => 'US',
  IPAddr.new('208.67.220.0/24') => 'US',
}.freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.custom_resolverObject

Returns the value of attribute custom_resolver.



98
99
100
# File 'lib/otto/privacy/geo_resolver.rb', line 98

def custom_resolver
  @custom_resolver
end

Class Method Details

.resolve(ip, env = {}) ⇒ String

Resolve country code for an IP address

Resolution priority:

  1. CDN/infrastructure provider headers (Cloudflare, AWS, Fastly, etc.)

  2. Basic IP range detection for major countries/providers

  3. Return ‘**’ for unknown

Parameters:

  • ip (String)

    IP address to resolve

  • env (Hash) (defaults to: {})

    Rack environment (may contain geo headers)

Returns:

  • (String)

    ISO 3166-1 alpha-2 country code or ‘**’



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/otto/privacy/geo_resolver.rb', line 127

def self.resolve(ip, env = {})
  return UNKNOWN if ip.nil? || ip.empty?

  # Check CDN/infrastructure headers in priority order
  # Priority based on reliability and deployment frequency
  country = check_geo_headers(env)
  return country if country

  # Try custom resolver if configured
  if @custom_resolver
    begin
      country = @custom_resolver.call(ip, env)
      return country if country && valid_country_code?(country)
    rescue StandardError => e
      # Log error but don't crash - fall through to built-in detection
      warn "GeoResolver custom resolver error: #{e.message}" if $DEBUG
    end
  end

  # Fallback: Basic range detection
  detect_by_range(ip)
rescue IPAddr::InvalidAddressError
  UNKNOWN
end