mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 01:10:43 +08:00
`DiscourseIpInfo#get` returns MaxMind IP data by default. The admin IP info endpoint asks for a hostname with `resolve_hostname: true`, which makes `DiscourseIpInfo` run a reverse DNS PTR lookup through `Resolv::DNS#getname` and add `hostname` when that lookup succeeds. That resolver call did not configure `Resolv::DNS#timeouts`, so Ruby used its default retry schedule. Ruby 3.4's [`Resolv::DNS::Config#generate_timeouts`](https://github.com/ruby/ruby/blob/v3_4_0/lib/resolv.rb#L1133-L1140) starts at 5 seconds and derives the later attempts from the configured nameserver count. With one configured nameserver, that generates `[5, 10, 20, 40]`. A reverse DNS server that does not answer can therefore keep the request busy long enough to hit Discourse's production [Pitchfork timeout of 30 seconds](https://github.com/discourse/discourse/blob/main/config/pitchfork.conf.rb#L34-L36). When that happens, the whole `/admin/users/ip-info.json` request can fail even though the MaxMind location data has already been collected. This PR bounds the reverse DNS part of IP info lookups. The hostname is useful context, but it is less critical than the IP location data, so it is fine to leave it out when reverse DNS fails. Key technical changes: 1. Add `HOSTNAME_LOOKUP_TIMEOUT_SECONDS` to `DiscourseIpInfo` and set it to 1 second. The hostname lookup gets a short budget before Discourse returns the location data without `hostname`. 2. Apply that timeout to the `Resolv::DNS` instance before requesting the hostname. This keeps the timeout decision at the shared IP info boundary instead of making individual callers handle DNS behavior. 3. Treat `Resolv::ResolvError` and `Timeout::Error` as a missing hostname. Reverse DNS failures no longer discard the location fields that were already populated from MaxMind. 4. Keep the existing API shape for `/admin/users/ip-info.json`. Successful hostname lookups still include `hostname`, failed or interrupted hostname lookups omit it, and the existing staff permission behavior is unchanged.
178 lines
5.4 KiB
Ruby
Vendored
178 lines
5.4 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "maxminddb"
|
|
require "resolv"
|
|
|
|
class DiscourseIpInfo
|
|
include Singleton
|
|
|
|
HOSTNAME_LOOKUP_TIMEOUT_SECONDS = 1
|
|
|
|
def initialize
|
|
open_db(DiscourseIpInfo.path)
|
|
end
|
|
|
|
def open_db(path)
|
|
@loc_mmdb = mmdb_load(File.join(path, "GeoLite2-City.mmdb"))
|
|
@asn_mmdb = mmdb_load(File.join(path, "GeoLite2-ASN.mmdb"))
|
|
@cache = LruRedux::ThreadSafeCache.new(2000)
|
|
end
|
|
|
|
def self.path
|
|
@path ||= Rails.root.join("vendor/data").to_s
|
|
end
|
|
|
|
def self.mmdb_path(name)
|
|
File.join(path, "#{name}.mmdb")
|
|
end
|
|
|
|
def self.mmdb_download(name)
|
|
extra_headers = {}
|
|
|
|
url =
|
|
if GlobalSetting.maxmind_mirror_url.present?
|
|
File.join(GlobalSetting.maxmind_mirror_url, "#{name}.tar.gz").to_s
|
|
else
|
|
license_key = GlobalSetting.maxmind_license_key
|
|
|
|
if license_key.blank?
|
|
STDERR.puts "MaxMind IP database download requires an account ID and a license key"
|
|
STDERR.puts "Please set DISCOURSE_MAXMIND_ACCOUNT_ID and DISCOURSE_MAXMIND_LICENSE_KEY. See https://meta.discourse.org/t/configure-maxmind-for-reverse-ip-lookups/173941 for more details."
|
|
return
|
|
end
|
|
|
|
account_id = GlobalSetting.maxmind_account_id
|
|
|
|
if account_id.present?
|
|
extra_headers[
|
|
"Authorization"
|
|
] = "Basic #{Base64.strict_encode64("#{account_id}:#{license_key}")}"
|
|
|
|
"https://download.maxmind.com/geoip/databases/#{name}/download?suffix=tar.gz"
|
|
else
|
|
# This URL is not documented by MaxMind, but it works but we don't know when it will stop working. Therefore,
|
|
# we are deprecating this in 3.3 and will remove it in 3.4. An admin dashboard warning has been added to inform
|
|
# site admins about this deprecation. See `ProblemCheck::MaxmindDbConfiguration` for more information.
|
|
"https://download.maxmind.com/app/geoip_download?license_key=#{license_key}&edition_id=#{name}&suffix=tar.gz"
|
|
end
|
|
end
|
|
|
|
gz_file =
|
|
FileHelper.download(
|
|
url,
|
|
max_file_size: 100.megabytes,
|
|
tmp_file_name: "#{name}.gz",
|
|
validate_uri: false,
|
|
follow_redirect: true,
|
|
extra_headers:,
|
|
)
|
|
|
|
filename = File.basename(gz_file.path)
|
|
|
|
dir = "#{Dir.tmpdir}/#{SecureRandom.hex}"
|
|
|
|
Discourse::Utils.execute_command("mkdir", "-p", dir)
|
|
Discourse::Utils.execute_command("cp", gz_file.path, "#{dir}/#{filename}")
|
|
Discourse::Utils.execute_command("tar", "-xzvf", "#{dir}/#{filename}", chdir: dir)
|
|
|
|
Dir["#{dir}/**/*.mmdb"].each { |f| FileUtils.mv(f, mmdb_path(name)) }
|
|
rescue => e
|
|
Discourse.warn_exception(e, message: "MaxMind database #{name} download failed.")
|
|
ensure
|
|
FileUtils.rm_r(dir, force: true) if dir
|
|
gz_file&.close!
|
|
end
|
|
|
|
def mmdb_load(filepath)
|
|
MaxMindDB.new(filepath, MaxMindDB::LOW_MEMORY_FILE_READER)
|
|
rescue Errno::ENOENT => e
|
|
Rails.logger.warn("MaxMindDB (#{filepath}) could not be found: #{e}")
|
|
nil
|
|
rescue => e
|
|
Discourse.warn_exception(e, message: "MaxMindDB (#{filepath}) could not be loaded.")
|
|
nil
|
|
end
|
|
|
|
def lookup(ip, locale: :en, resolve_hostname: false)
|
|
ret = {}
|
|
return ret if ip.blank?
|
|
|
|
if @loc_mmdb
|
|
begin
|
|
result = @loc_mmdb.lookup(ip)
|
|
if result&.found?
|
|
ret[:country] = result.country.name(locale) || result.country.name
|
|
ret[:country_code] = result.country.iso_code
|
|
ret[:region] = result.subdivisions.most_specific.name(locale) ||
|
|
result.subdivisions.most_specific.name
|
|
ret[:city] = result.city.name(locale) || result.city.name
|
|
ret[:latitude] = result.location.latitude
|
|
ret[:longitude] = result.location.longitude
|
|
ret[:location] = ret.values_at(:city, :region, :country).reject(&:blank?).uniq.join(", ")
|
|
|
|
# used by plugins or API to locate users more accurately
|
|
ret[:geoname_ids] = [
|
|
result.continent.geoname_id,
|
|
result.country.geoname_id,
|
|
result.city.geoname_id,
|
|
*result.subdivisions.map(&:geoname_id),
|
|
]
|
|
ret[:geoname_ids].compact!
|
|
end
|
|
rescue => e
|
|
Discourse.warn_exception(
|
|
e,
|
|
message: "IP #{ip} could not be looked up in MaxMind GeoLite2-City database.",
|
|
)
|
|
end
|
|
end
|
|
|
|
if @asn_mmdb
|
|
begin
|
|
result = @asn_mmdb.lookup(ip)
|
|
if result&.found?
|
|
result = result.to_hash
|
|
ret[:asn] = result["autonomous_system_number"]
|
|
ret[:organization] = result["autonomous_system_organization"]
|
|
end
|
|
rescue => e
|
|
Discourse.warn_exception(
|
|
e,
|
|
message: "IP #{ip} could not be looked up in MaxMind GeoLite2-ASN database.",
|
|
)
|
|
end
|
|
end
|
|
|
|
if resolve_hostname
|
|
begin
|
|
dns = Resolv::DNS.new
|
|
dns.timeouts = HOSTNAME_LOOKUP_TIMEOUT_SECONDS
|
|
|
|
result = dns.getname(ip)
|
|
ret[:hostname] = result&.to_s
|
|
rescue Resolv::ResolvError, Timeout::Error
|
|
end
|
|
end
|
|
|
|
ret
|
|
end
|
|
|
|
def get(ip, locale: :en, resolve_hostname: false)
|
|
ip = ip.to_s
|
|
locale = locale.to_s.sub("_", "-")
|
|
|
|
@cache["#{ip}-#{locale}-#{resolve_hostname}"] ||= lookup(
|
|
ip,
|
|
locale: locale,
|
|
resolve_hostname: resolve_hostname,
|
|
)
|
|
end
|
|
|
|
def self.open_db(path)
|
|
instance.open_db(path)
|
|
end
|
|
|
|
def self.get(ip, locale: :en, resolve_hostname: false)
|
|
instance.get(ip, locale: locale, resolve_hostname: resolve_hostname)
|
|
end
|
|
end
|