Accepts incoming events and correctly parses them into events. GeoLite2 integration complete"
This commit is contained in:
@@ -67,7 +67,8 @@ class Api::EventsController < ApplicationController
|
||||
headers = {}
|
||||
important_headers.each do |header|
|
||||
value = request.headers[header]
|
||||
headers[header] = value if value.present?
|
||||
# Standardize headers to lower case during import phase
|
||||
headers[header.downcase] = value if value.present?
|
||||
end
|
||||
|
||||
headers
|
||||
|
||||
67
app/controllers/api/rules_controller.rb
Normal file
67
app/controllers/api/rules_controller.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class RulesController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
before_action :authenticate_project!
|
||||
before_action :check_project_enabled
|
||||
|
||||
# GET /api/:public_key/rules/version
|
||||
# Quick version check - returns latest updated_at timestamp
|
||||
def version
|
||||
render json: {
|
||||
version: Rule.latest_version,
|
||||
count: Rule.active.count,
|
||||
sampling: HubLoad.current_sampling
|
||||
}
|
||||
end
|
||||
|
||||
# GET /api/:public_key/rules?since=2024-11-03T12:00:00.000Z
|
||||
# Incremental sync - returns rules updated since timestamp
|
||||
# GET /api/:public_key/rules
|
||||
# Full sync - returns all active rules
|
||||
def index
|
||||
rules = if params[:since].present?
|
||||
# Incremental sync
|
||||
since_time = parse_timestamp(params[:since])
|
||||
Rule.since(since_time)
|
||||
else
|
||||
# Full sync - only return enabled rules
|
||||
Rule.active.sync_order
|
||||
end
|
||||
|
||||
render json: {
|
||||
version: Rule.latest_version,
|
||||
sampling: HubLoad.current_sampling,
|
||||
rules: rules.map(&:to_agent_format)
|
||||
}
|
||||
rescue ArgumentError => e
|
||||
render json: { error: "Invalid timestamp format: #{e.message}" }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authenticate_project!
|
||||
public_key = params[:public_key] || params[:project_id]
|
||||
|
||||
@project = Project.find_by(public_key: public_key)
|
||||
|
||||
unless @project
|
||||
render json: { error: "Invalid project key" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
def check_project_enabled
|
||||
unless @project.enabled?
|
||||
render json: { error: "Project is disabled" }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
def parse_timestamp(timestamp_str)
|
||||
Time.parse(timestamp_str)
|
||||
rescue ArgumentError => e
|
||||
raise ArgumentError, "Invalid timestamp format. Expected ISO8601 format (e.g., 2024-11-03T12:00:00.000Z)"
|
||||
end
|
||||
end
|
||||
end
|
||||
50
app/jobs/expired_rules_cleanup_job.rb
Normal file
50
app/jobs/expired_rules_cleanup_job.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ExpiredRulesCleanupJob - Disables rules that have expired
|
||||
#
|
||||
# This job runs periodically (hourly) to find rules with expires_at in the past
|
||||
# and disables them. Agents will pick up these disabled rules in their next sync
|
||||
# and remove them from their local evaluation tables.
|
||||
#
|
||||
# Schedule: Every hour (configured in initializer or cron)
|
||||
class ExpiredRulesCleanupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
expired_rules = Rule.enabled.expired
|
||||
|
||||
Rails.logger.info "ExpiredRulesCleanupJob: Found #{expired_rules.count} expired rules"
|
||||
|
||||
return if expired_rules.empty?
|
||||
|
||||
# Disable all expired rules in a single update
|
||||
count = expired_rules.update_all(
|
||||
enabled: false,
|
||||
updated_at: Time.current
|
||||
)
|
||||
|
||||
Rails.logger.info "ExpiredRulesCleanupJob: Disabled #{count} expired rules"
|
||||
|
||||
# Optionally: Clean up old disabled rules after a retention period
|
||||
cleanup_old_disabled_rules if should_cleanup_old_rules?
|
||||
|
||||
count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_cleanup_old_rules?
|
||||
# Only cleanup on first hour of the day to avoid too frequent deletion
|
||||
Time.current.hour == 1
|
||||
end
|
||||
|
||||
def cleanup_old_disabled_rules
|
||||
# Delete disabled rules older than 30 days (keep for audit trail)
|
||||
old_rules = Rule.disabled.where("updated_at < ?", 30.days.ago)
|
||||
|
||||
if old_rules.any?
|
||||
count = old_rules.delete_all
|
||||
Rails.logger.info "ExpiredRulesCleanupJob: Deleted #{count} old disabled rules (>30 days)"
|
||||
end
|
||||
end
|
||||
end
|
||||
117
app/jobs/path_scanner_detector_job.rb
Normal file
117
app/jobs/path_scanner_detector_job.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# PathScannerDetectorJob - Detects IPs hitting scanner paths and auto-bans them
|
||||
#
|
||||
# This job analyzes recent events to find IPs hitting common scanner/bot paths
|
||||
# like /.env, /.git, /wp-admin, etc. When detected, it creates temporary IP
|
||||
# block rules that expire after 24 hours.
|
||||
#
|
||||
# Schedule: Every 5 minutes (configured in initializer or cron)
|
||||
class PathScannerDetectorJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# Common paths that scanners/bots hit
|
||||
SCANNER_PATHS = %w[
|
||||
/.env
|
||||
/.git
|
||||
/wp-admin
|
||||
/wp-login.php
|
||||
/phpMyAdmin
|
||||
/phpmyadmin
|
||||
/.aws
|
||||
/.ssh
|
||||
/admin
|
||||
/administrator
|
||||
/.config
|
||||
/backup
|
||||
/db_backup
|
||||
/.DS_Store
|
||||
/web.config
|
||||
].freeze
|
||||
|
||||
# Minimum hits to be considered a scanner
|
||||
MIN_SCANNER_HITS = 3
|
||||
|
||||
# Look back window
|
||||
LOOKBACK_WINDOW = 5.minutes
|
||||
|
||||
# Ban duration
|
||||
BAN_DURATION = 24.hours
|
||||
|
||||
def perform
|
||||
scanner_ips = detect_scanner_ips
|
||||
|
||||
Rails.logger.info "PathScannerDetectorJob: Found #{scanner_ips.count} scanner IPs"
|
||||
|
||||
scanner_ips.each do |ip_data|
|
||||
create_ban_rule(ip_data)
|
||||
end
|
||||
|
||||
scanner_ips.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def detect_scanner_ips
|
||||
# Find IPs that have hit scanner paths multiple times recently
|
||||
Event
|
||||
.where("timestamp > ?", LOOKBACK_WINDOW.ago)
|
||||
.where("request_path IN (?)", SCANNER_PATHS)
|
||||
.group(:ip_address)
|
||||
.select("ip_address, COUNT(*) as hit_count, GROUP_CONCAT(DISTINCT request_path) as paths")
|
||||
.having("COUNT(*) >= ?", MIN_SCANNER_HITS)
|
||||
.map do |event|
|
||||
{
|
||||
ip: event.ip_address,
|
||||
hit_count: event.hit_count,
|
||||
paths: event.paths.to_s.split(",")
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def create_ban_rule(ip_data)
|
||||
ip = ip_data[:ip]
|
||||
|
||||
# Check if rule already exists for this IP
|
||||
existing_rule = Rule.active.network_rules.find_by(
|
||||
"conditions ->> 'cidr' = ?", "#{ip}/32"
|
||||
)
|
||||
|
||||
if existing_rule
|
||||
Rails.logger.info "PathScannerDetectorJob: Rule already exists for #{ip}, skipping"
|
||||
return
|
||||
end
|
||||
|
||||
# Determine if IPv4 or IPv6
|
||||
addr = IPAddr.new(ip)
|
||||
rule_type = addr.ipv4? ? "network_v4" : "network_v6"
|
||||
|
||||
# Create the ban rule
|
||||
rule = Rule.create!(
|
||||
rule_type: rule_type,
|
||||
action: "deny",
|
||||
conditions: { cidr: "#{ip}/32" },
|
||||
priority: 32,
|
||||
expires_at: BAN_DURATION.from_now,
|
||||
source: "auto:scanner_detected",
|
||||
enabled: true,
|
||||
metadata: {
|
||||
reason: "Scanner detected: hit #{ip_data[:paths].join(', ')}",
|
||||
hit_count: ip_data[:hit_count],
|
||||
paths: ip_data[:paths],
|
||||
detected_at: Time.current.iso8601,
|
||||
auto_generated: true
|
||||
}
|
||||
)
|
||||
|
||||
Rails.logger.info "PathScannerDetectorJob: Created ban rule #{rule.id} for #{ip} (expires: #{rule.expires_at})"
|
||||
|
||||
rule
|
||||
rescue IPAddr::InvalidAddressError => e
|
||||
Rails.logger.error "PathScannerDetectorJob: Invalid IP address #{ip}: #{e.message}"
|
||||
nil
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "PathScannerDetectorJob: Failed to create rule for #{ip}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,15 @@ class ProcessWafEventJob < ApplicationJob
|
||||
# Create the WAF event record
|
||||
event = Event.create_from_waf_payload!(event_id, single_event_data, project)
|
||||
|
||||
# Enrich with geo-location data if missing
|
||||
if event.ip_address.present? && event.country_code.blank?
|
||||
begin
|
||||
event.enrich_geo_location!
|
||||
rescue => e
|
||||
Rails.logger.warn "Failed to enrich geo location for event #{event.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
# Trigger analytics processing
|
||||
ProcessWafAnalyticsJob.perform_later(project_id: project_id, event_id: event.id)
|
||||
|
||||
|
||||
66
app/jobs/update_geo_ip_database_job.rb
Normal file
66
app/jobs/update_geo_ip_database_job.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UpdateGeoIpDatabaseJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# Schedule this job to run weekly to keep the GeoIP database updated
|
||||
# Use: UpdateGeoIpDatabaseJob.set(wait: 1.week).perform_later
|
||||
# Or set up in config/schedule.rb for recurring execution
|
||||
|
||||
def perform(force_update: false)
|
||||
return unless auto_update_enabled?
|
||||
|
||||
Rails.logger.info "Starting GeoIP database update check"
|
||||
|
||||
if should_update_database? || force_update
|
||||
success = GeoIpService.update_database!
|
||||
|
||||
if success
|
||||
Rails.logger.info "GeoIP database successfully updated"
|
||||
else
|
||||
Rails.logger.error "Failed to update GeoIP database"
|
||||
end
|
||||
else
|
||||
Rails.logger.info "GeoIP database is up to date, no update needed"
|
||||
end
|
||||
|
||||
# No cleanup needed with file-system approach
|
||||
rescue => e
|
||||
Rails.logger.error "Error in UpdateGeoIpDatabaseJob: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auto_update_enabled?
|
||||
Rails.application.config.maxmind.auto_update_enabled
|
||||
end
|
||||
|
||||
def should_update_database?
|
||||
config = Rails.application.config.maxmind
|
||||
database_path = default_database_path
|
||||
|
||||
# Check if database file exists
|
||||
return true unless File.exist?(database_path)
|
||||
|
||||
# Check if database is outdated
|
||||
max_age_days = config.max_age_days
|
||||
file_mtime = File.mtime(database_path)
|
||||
return true if file_mtime < max_age_days.days.ago
|
||||
|
||||
# Check if database file is readable and valid
|
||||
begin
|
||||
# Try to open the database to verify it's valid
|
||||
MaxMind::DB.new(database_path)
|
||||
false
|
||||
rescue => e
|
||||
Rails.logger.warn "GeoIP database file appears to be corrupted: #{e.message}"
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def default_database_path
|
||||
config = Rails.application.config.maxmind
|
||||
File.join(config.storage_path, config.database_filename)
|
||||
end
|
||||
end
|
||||
@@ -88,40 +88,63 @@ class Event < ApplicationRecord
|
||||
after_validation :normalize_event_fields, if: :should_normalize?
|
||||
|
||||
def self.create_from_waf_payload!(event_id, payload, project)
|
||||
# Normalize headers in payload during import phase
|
||||
normalized_payload = normalize_payload_headers(payload)
|
||||
|
||||
# Create the WAF request event
|
||||
create!(
|
||||
project: project,
|
||||
event_id: event_id,
|
||||
timestamp: parse_timestamp(payload["timestamp"]),
|
||||
payload: payload,
|
||||
timestamp: parse_timestamp(normalized_payload["timestamp"]),
|
||||
payload: normalized_payload,
|
||||
|
||||
# WAF-specific fields
|
||||
ip_address: payload.dig("request", "ip"),
|
||||
user_agent: payload.dig("request", "headers", "User-Agent"),
|
||||
request_method: payload.dig("request", "method")&.downcase,
|
||||
request_path: payload.dig("request", "path"),
|
||||
request_url: payload.dig("request", "url"),
|
||||
request_protocol: payload.dig("request", "protocol"),
|
||||
response_status: payload.dig("response", "status_code"),
|
||||
response_time_ms: payload.dig("response", "duration_ms"),
|
||||
waf_action: normalize_action(payload["waf_action"]), # Normalize incoming action values
|
||||
rule_matched: payload["rule_matched"],
|
||||
blocked_reason: payload["blocked_reason"],
|
||||
ip_address: normalized_payload.dig("request", "ip"),
|
||||
user_agent: normalized_payload.dig("request", "headers", "user-agent") || normalized_payload.dig("request", "headers", "User-Agent"),
|
||||
# request_method will be set by extract_fields_from_payload + normalize_event_fields
|
||||
request_path: normalized_payload.dig("request", "path"),
|
||||
request_url: normalized_payload.dig("request", "url"),
|
||||
# request_protocol will be set by extract_fields_from_payload + normalize_event_fields
|
||||
response_status: normalized_payload.dig("response", "status_code"),
|
||||
response_time_ms: normalized_payload.dig("response", "duration_ms"),
|
||||
waf_action: normalize_action(normalized_payload["waf_action"]), # Normalize incoming action values
|
||||
rule_matched: normalized_payload["rule_matched"],
|
||||
blocked_reason: normalized_payload["blocked_reason"],
|
||||
|
||||
# Server/Environment info
|
||||
server_name: payload["server_name"],
|
||||
environment: payload["environment"],
|
||||
server_name: normalized_payload["server_name"],
|
||||
environment: normalized_payload["environment"],
|
||||
|
||||
# Geographic data
|
||||
country_code: payload.dig("geo", "country_code"),
|
||||
city: payload.dig("geo", "city"),
|
||||
country_code: normalized_payload.dig("geo", "country_code"),
|
||||
city: normalized_payload.dig("geo", "city"),
|
||||
|
||||
# WAF agent info
|
||||
agent_version: payload.dig("agent", "version"),
|
||||
agent_name: payload.dig("agent", "name")
|
||||
agent_version: normalized_payload.dig("agent", "version"),
|
||||
agent_name: normalized_payload.dig("agent", "name")
|
||||
)
|
||||
end
|
||||
|
||||
# Normalize headers in payload to lower case during import phase
|
||||
def self.normalize_payload_headers(payload)
|
||||
return payload unless payload.is_a?(Hash)
|
||||
|
||||
# Create a deep copy to avoid modifying the original
|
||||
normalized = Marshal.load(Marshal.dump(payload))
|
||||
|
||||
# Normalize request headers
|
||||
if normalized.dig("request", "headers")&.is_a?(Hash)
|
||||
normalized["request"]["headers"] = normalized["request"]["headers"].transform_keys(&:downcase)
|
||||
end
|
||||
|
||||
# Normalize response headers if they exist
|
||||
if normalized.dig("response", "headers")&.is_a?(Hash)
|
||||
normalized["response"]["headers"] = normalized["response"]["headers"].transform_keys(&:downcase)
|
||||
end
|
||||
|
||||
normalized
|
||||
end
|
||||
|
||||
def self.normalize_action(action)
|
||||
return "allow" if action.nil? || action.blank?
|
||||
|
||||
@@ -195,7 +218,8 @@ class Event < ApplicationRecord
|
||||
end
|
||||
|
||||
def headers
|
||||
payload&.dig("request", "headers") || {}
|
||||
raw_headers = payload&.dig("request", "headers") || {}
|
||||
normalize_headers(raw_headers)
|
||||
end
|
||||
|
||||
def query_params
|
||||
@@ -237,6 +261,69 @@ class Event < ApplicationRecord
|
||||
URI.parse(request_url).hostname rescue nil
|
||||
end
|
||||
|
||||
# Normalize headers to lower case keys during import phase
|
||||
def normalize_headers(headers)
|
||||
return {} unless headers.is_a?(Hash)
|
||||
|
||||
headers.transform_keys(&:downcase)
|
||||
end
|
||||
|
||||
# GeoIP enrichment methods
|
||||
def enrich_geo_location!
|
||||
return if ip_address.blank?
|
||||
return if country_code.present? # Already has geo data
|
||||
|
||||
country = GeoIpService.lookup_country(ip_address)
|
||||
update!(country_code: country) if country.present?
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to enrich geo location for event #{id}: #{e.message}"
|
||||
end
|
||||
|
||||
# Class method to enrich multiple events
|
||||
def self.enrich_geo_location_batch(events = nil)
|
||||
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
|
||||
geo_service = GeoIpService.new
|
||||
updated_count = 0
|
||||
|
||||
events.find_each do |event|
|
||||
next if event.country_code.present?
|
||||
|
||||
country = geo_service.lookup_country(event.ip_address)
|
||||
if country.present?
|
||||
event.update!(country_code: country)
|
||||
updated_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
updated_count
|
||||
end
|
||||
|
||||
# Lookup country code for this event's IP
|
||||
def lookup_country
|
||||
return country_code if country_code.present?
|
||||
return nil if ip_address.blank?
|
||||
|
||||
GeoIpService.lookup_country(ip_address)
|
||||
rescue => e
|
||||
Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Check if event has valid geo location data
|
||||
def has_geo_data?
|
||||
country_code.present? || city.present?
|
||||
end
|
||||
|
||||
# Get full geo location details
|
||||
def geo_location
|
||||
{
|
||||
country_code: country_code,
|
||||
city: city,
|
||||
ip_address: ip_address,
|
||||
has_data: has_geo_data?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_normalize?
|
||||
@@ -257,7 +344,12 @@ class Event < ApplicationRecord
|
||||
response_data = payload.dig("response") || {}
|
||||
|
||||
self.ip_address = request_data["ip"]
|
||||
self.user_agent = request_data.dig("headers", "User-Agent")
|
||||
|
||||
# Extract user agent with header name standardization
|
||||
headers = request_data["headers"] || {}
|
||||
normalized_headers = normalize_headers(headers)
|
||||
self.user_agent = normalized_headers["user-agent"] || normalized_headers["User-Agent"]
|
||||
|
||||
self.request_path = request_data["path"]
|
||||
self.request_url = request_data["url"]
|
||||
self.response_status = response_data["status_code"]
|
||||
@@ -265,10 +357,11 @@ class Event < ApplicationRecord
|
||||
self.rule_matched = payload["rule_matched"]
|
||||
self.blocked_reason = payload["blocked_reason"]
|
||||
|
||||
# Store original values for normalization (these will be normalized to IDs)
|
||||
@raw_request_method = request_data["method"]
|
||||
@raw_request_protocol = request_data["protocol"]
|
||||
@raw_action = payload["waf_action"]
|
||||
# Store original values for normalization only if they don't exist yet
|
||||
# This prevents overwriting during multiple callback runs
|
||||
@raw_request_method ||= request_data["method"]
|
||||
@raw_request_protocol ||= request_data["protocol"]
|
||||
@raw_action ||= payload["waf_action"]
|
||||
|
||||
# Extract server/environment info
|
||||
self.server_name = payload["server_name"]
|
||||
|
||||
171
app/models/ipv4_range.rb
Normal file
171
app/models/ipv4_range.rb
Normal file
@@ -0,0 +1,171 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Ipv4Range - Stores IPv4 network ranges with IP intelligence metadata
|
||||
#
|
||||
# Optimized for fast range lookups using network_start/network_end integers.
|
||||
# Stores metadata about IP ranges including ASN, company, geographic info,
|
||||
# and flags for datacenter/proxy/VPN detection.
|
||||
class Ipv4Range < ApplicationRecord
|
||||
# Validations
|
||||
validates :network_start, presence: true
|
||||
validates :network_end, presence: true
|
||||
validates :network_prefix, presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 32 }
|
||||
|
||||
# Callbacks
|
||||
before_validation :calculate_range, if: -> { cidr.present? }
|
||||
|
||||
# Scopes for common queries
|
||||
scope :datacenter, -> { where(is_datacenter: true) }
|
||||
scope :proxy, -> { where(is_proxy: true) }
|
||||
scope :vpn, -> { where(is_vpn: true) }
|
||||
scope :by_country, ->(country) { where(ip_api_country: country) }
|
||||
scope :by_company, ->(company) { where(company: company) }
|
||||
scope :by_asn, ->(asn) { where(asn: asn) }
|
||||
|
||||
# Virtual attribute for setting IP via CIDR notation
|
||||
attr_accessor :cidr
|
||||
|
||||
# Find ranges that contain a specific IPv4 address
|
||||
def self.contains_ip(ip_string)
|
||||
ip_addr = IPAddr.new(ip_string)
|
||||
raise ArgumentError, "Not an IPv4 address" unless ip_addr.ipv4?
|
||||
|
||||
ip_int = ip_addr.to_i
|
||||
|
||||
where("? BETWEEN network_start AND network_end", ip_int)
|
||||
.order(network_prefix: :desc) # Most specific first
|
||||
end
|
||||
|
||||
# Check if this range contains a specific IP
|
||||
def contains_ip?(ip_string)
|
||||
ip_addr = IPAddr.new(ip_string)
|
||||
return false unless ip_addr.ipv4?
|
||||
|
||||
ip_int = ip_addr.to_i
|
||||
ip_int >= network_start && ip_int <= network_end
|
||||
end
|
||||
|
||||
# Get CIDR notation for this range
|
||||
def to_cidr
|
||||
return nil unless network_start.present?
|
||||
|
||||
ip_addr = IPAddr.new(network_start, Socket::AF_INET)
|
||||
"#{ip_addr}/#{network_prefix}"
|
||||
end
|
||||
|
||||
# String representation
|
||||
def to_s
|
||||
to_cidr || "Ipv4Range##{id}"
|
||||
end
|
||||
|
||||
# Convenience methods for JSON fields
|
||||
def abuser_scores_hash
|
||||
abuser_scores ? JSON.parse(abuser_scores) : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def abuser_scores_hash=(hash)
|
||||
self.abuser_scores = hash.to_json
|
||||
end
|
||||
|
||||
def additional_data_hash
|
||||
additional_data ? JSON.parse(additional_data) : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def additional_data_hash=(hash)
|
||||
self.additional_data = hash.to_json
|
||||
end
|
||||
|
||||
# GeoIP lookup methods
|
||||
def geo_lookup_country!
|
||||
return if ip_api_country.present? || geo2_country.present?
|
||||
|
||||
# Use the first IP in the range for lookup
|
||||
sample_ip = IPAddr.new(network_start, Socket::AF_INET).to_s
|
||||
country = GeoIpService.lookup_country(sample_ip)
|
||||
|
||||
if country.present?
|
||||
# Update both country fields for redundancy
|
||||
update!(ip_api_country: country, geo2_country: country)
|
||||
country
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to lookup geo location for IPv4 range #{id}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def geo_lookup_country
|
||||
return ip_api_country if ip_api_country.present?
|
||||
return geo2_country if geo2_country.present?
|
||||
|
||||
# Use the first IP in the range for lookup
|
||||
sample_ip = IPAddr.new(network_start, Socket::AF_INET).to_s
|
||||
GeoIpService.lookup_country(sample_ip)
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to lookup geo location for IPv4 range #{id}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Check if this range has any country information
|
||||
def has_country_info?
|
||||
ip_api_country.present? || geo2_country.present?
|
||||
end
|
||||
|
||||
# Get the best available country code
|
||||
def primary_country
|
||||
ip_api_country || geo2_country
|
||||
end
|
||||
|
||||
# Class method to lookup country for any IP in the range
|
||||
def self.lookup_country_by_ip(ip_string)
|
||||
range = contains_ip(ip_string).first
|
||||
return nil unless range
|
||||
|
||||
range.geo_lookup_country
|
||||
end
|
||||
|
||||
# Class method to enrich ranges without country data
|
||||
def self.enrich_missing_geo_data(limit: 1000)
|
||||
ranges_without_geo = where(ip_api_country: [nil, ''], geo2_country: [nil, ''])
|
||||
.limit(limit)
|
||||
|
||||
updated_count = 0
|
||||
geo_service = GeoIpService.new
|
||||
|
||||
ranges_without_geo.find_each do |range|
|
||||
country = geo_service.lookup_country(IPAddr.new(range.network_start, Socket::AF_INET).to_s)
|
||||
if country.present?
|
||||
range.update!(ip_api_country: country, geo2_country: country)
|
||||
updated_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
updated_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Calculate network_start and network_end from CIDR notation
|
||||
def calculate_range
|
||||
return unless cidr.present?
|
||||
|
||||
ip_addr = IPAddr.new(cidr)
|
||||
raise ArgumentError, "Not an IPv4 CIDR" unless ip_addr.ipv4?
|
||||
|
||||
# Get prefix from CIDR
|
||||
self.network_prefix = cidr.split("/").last.to_i
|
||||
|
||||
# Calculate network range
|
||||
first_ip = ip_addr.to_range.first
|
||||
last_ip = ip_addr.to_range.last
|
||||
|
||||
self.network_start = first_ip.to_i
|
||||
self.network_end = last_ip.to_i
|
||||
rescue IPAddr::InvalidAddressError => e
|
||||
errors.add(:cidr, "invalid IPv4 CIDR notation: #{e.message}")
|
||||
end
|
||||
end
|
||||
171
app/models/ipv6_range.rb
Normal file
171
app/models/ipv6_range.rb
Normal file
@@ -0,0 +1,171 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Ipv6Range - Stores IPv6 network ranges with IP intelligence metadata
|
||||
#
|
||||
# Optimized for fast range lookups using network_start/network_end binary storage.
|
||||
# Stores metadata about IP ranges including ASN, company, geographic info,
|
||||
# and flags for datacenter/proxy/VPN detection.
|
||||
class Ipv6Range < ApplicationRecord
|
||||
# Validations
|
||||
validates :network_start, presence: true
|
||||
validates :network_end, presence: true
|
||||
validates :network_prefix, presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 128 }
|
||||
|
||||
# Callbacks
|
||||
before_validation :calculate_range, if: -> { cidr.present? }
|
||||
|
||||
# Scopes for common queries
|
||||
scope :datacenter, -> { where(is_datacenter: true) }
|
||||
scope :proxy, -> { where(is_proxy: true) }
|
||||
scope :vpn, -> { where(is_vpn: true) }
|
||||
scope :by_country, ->(country) { where(ip_api_country: country) }
|
||||
scope :by_company, ->(company) { where(company: company) }
|
||||
scope :by_asn, ->(asn) { where(asn: asn) }
|
||||
|
||||
# Virtual attribute for setting IP via CIDR notation
|
||||
attr_accessor :cidr
|
||||
|
||||
# Find ranges that contain a specific IPv6 address
|
||||
def self.contains_ip(ip_string)
|
||||
ip_addr = IPAddr.new(ip_string)
|
||||
raise ArgumentError, "Not an IPv6 address" unless ip_addr.ipv6?
|
||||
|
||||
ip_bytes = ip_addr.hton
|
||||
|
||||
where("? BETWEEN network_start AND network_end", ip_bytes)
|
||||
.order(network_prefix: :desc) # Most specific first
|
||||
end
|
||||
|
||||
# Check if this range contains a specific IP
|
||||
def contains_ip?(ip_string)
|
||||
ip_addr = IPAddr.new(ip_string)
|
||||
return false unless ip_addr.ipv6?
|
||||
|
||||
ip_bytes = ip_addr.hton
|
||||
ip_bytes >= network_start && ip_bytes <= network_end
|
||||
end
|
||||
|
||||
# Get CIDR notation for this range
|
||||
def to_cidr
|
||||
return nil unless network_start.present?
|
||||
|
||||
ip_addr = IPAddr.new_ntoh(network_start)
|
||||
"#{ip_addr}/#{network_prefix}"
|
||||
end
|
||||
|
||||
# String representation
|
||||
def to_s
|
||||
to_cidr || "Ipv6Range##{id}"
|
||||
end
|
||||
|
||||
# Convenience methods for JSON fields
|
||||
def abuser_scores_hash
|
||||
abuser_scores ? JSON.parse(abuser_scores) : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def abuser_scores_hash=(hash)
|
||||
self.abuser_scores = hash.to_json
|
||||
end
|
||||
|
||||
def additional_data_hash
|
||||
additional_data ? JSON.parse(additional_data) : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def additional_data_hash=(hash)
|
||||
self.additional_data = hash.to_json
|
||||
end
|
||||
|
||||
# GeoIP lookup methods
|
||||
def geo_lookup_country!
|
||||
return if ip_api_country.present? || geo2_country.present?
|
||||
|
||||
# Use the first IP in the range for lookup
|
||||
sample_ip = IPAddr.new_ntoh(network_start).to_s
|
||||
country = GeoIpService.lookup_country(sample_ip)
|
||||
|
||||
if country.present?
|
||||
# Update both country fields for redundancy
|
||||
update!(ip_api_country: country, geo2_country: country)
|
||||
country
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to lookup geo location for IPv6 range #{id}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def geo_lookup_country
|
||||
return ip_api_country if ip_api_country.present?
|
||||
return geo2_country if geo2_country.present?
|
||||
|
||||
# Use the first IP in the range for lookup
|
||||
sample_ip = IPAddr.new_ntoh(network_start).to_s
|
||||
GeoIpService.lookup_country(sample_ip)
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to lookup geo location for IPv6 range #{id}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Check if this range has any country information
|
||||
def has_country_info?
|
||||
ip_api_country.present? || geo2_country.present?
|
||||
end
|
||||
|
||||
# Get the best available country code
|
||||
def primary_country
|
||||
ip_api_country || geo2_country
|
||||
end
|
||||
|
||||
# Class method to lookup country for any IP in the range
|
||||
def self.lookup_country_by_ip(ip_string)
|
||||
range = contains_ip(ip_string).first
|
||||
return nil unless range
|
||||
|
||||
range.geo_lookup_country
|
||||
end
|
||||
|
||||
# Class method to enrich ranges without country data
|
||||
def self.enrich_missing_geo_data(limit: 1000)
|
||||
ranges_without_geo = where(ip_api_country: [nil, ''], geo2_country: [nil, ''])
|
||||
.limit(limit)
|
||||
|
||||
updated_count = 0
|
||||
geo_service = GeoIpService.new
|
||||
|
||||
ranges_without_geo.find_each do |range|
|
||||
country = geo_service.lookup_country(IPAddr.new_ntoh(range.network_start).to_s)
|
||||
if country.present?
|
||||
range.update!(ip_api_country: country, geo2_country: country)
|
||||
updated_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
updated_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Calculate network_start and network_end from CIDR notation
|
||||
def calculate_range
|
||||
return unless cidr.present?
|
||||
|
||||
ip_addr = IPAddr.new(cidr)
|
||||
raise ArgumentError, "Not an IPv6 CIDR" unless ip_addr.ipv6?
|
||||
|
||||
# Get prefix from CIDR
|
||||
self.network_prefix = cidr.split("/").last.to_i
|
||||
|
||||
# Calculate network range (binary format for IPv6)
|
||||
first_ip = ip_addr.to_range.first
|
||||
last_ip = ip_addr.to_range.last
|
||||
|
||||
self.network_start = first_ip.hton
|
||||
self.network_end = last_ip.hton
|
||||
rescue IPAddr::InvalidAddressError => e
|
||||
errors.add(:cidr, "invalid IPv6 CIDR notation: #{e.message}")
|
||||
end
|
||||
end
|
||||
@@ -1,63 +0,0 @@
|
||||
class NetworkRange < ApplicationRecord
|
||||
validates :ip_address, presence: true
|
||||
validates :network_prefix, presence: true, numericality: {greater_than_or_equal_to: 0, less_than_or_equal_to: 128}
|
||||
validates :ip_version, presence: true, inclusion: {in: [4, 6]}
|
||||
|
||||
# Convenience methods for JSON fields
|
||||
def abuser_scores_hash
|
||||
abuser_scores ? JSON.parse(abuser_scores) : {}
|
||||
end
|
||||
|
||||
def abuser_scores_hash=(hash)
|
||||
self.abuser_scores = hash.to_json
|
||||
end
|
||||
|
||||
def additional_data_hash
|
||||
additional_data ? JSON.parse(additional_data) : {}
|
||||
end
|
||||
|
||||
def additional_data_hash=(hash)
|
||||
self.additional_data = hash.to_json
|
||||
end
|
||||
|
||||
# Scope methods for common queries
|
||||
scope :ipv4, -> { where(ip_version: 4) }
|
||||
scope :ipv6, -> { where(ip_version: 6) }
|
||||
scope :datacenter, -> { where(is_datacenter: true) }
|
||||
scope :proxy, -> { where(is_proxy: true) }
|
||||
scope :vpn, -> { where(is_vpn: true) }
|
||||
scope :by_country, ->(country) { where(ip_api_country: country) }
|
||||
scope :by_company, ->(company) { where(company: company) }
|
||||
scope :by_asn, ->(asn) { where(asn: asn) }
|
||||
|
||||
# Find network ranges that contain a specific IP address
|
||||
def self.contains_ip(ip_string)
|
||||
ip_bytes = IPAddr.new(ip_string).hton
|
||||
version = ip_string.include?(":") ? 6 : 4
|
||||
|
||||
where(ip_version: version).select do |range|
|
||||
range.contains_ip_bytes?(ip_bytes)
|
||||
end
|
||||
end
|
||||
|
||||
def contains_ip?(ip_string)
|
||||
contains_ip_bytes?(IPAddr.new(ip_string).hton)
|
||||
end
|
||||
|
||||
def to_s
|
||||
"#{ip_address_to_s}/#{network_prefix}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contains_ip_bytes?(ip_bytes)
|
||||
# This is a simplified version - you'll need proper network math here
|
||||
# For now, just check if the IP matches exactly
|
||||
ip_address == ip_bytes
|
||||
end
|
||||
|
||||
def ip_address_to_s
|
||||
# Convert binary IP back to string representation
|
||||
IPAddr.ntop(ip_address)
|
||||
end
|
||||
end
|
||||
@@ -1,126 +1,189 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Rule < ApplicationRecord
|
||||
belongs_to :rule_set
|
||||
# Rule types for the new architecture
|
||||
RULE_TYPES = %w[network_v4 network_v6 rate_limit path_pattern].freeze
|
||||
ACTIONS = %w[allow deny rate_limit redirect log].freeze
|
||||
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default].freeze
|
||||
|
||||
validates :rule_type, presence: true, inclusion: { in: RuleSet::RULE_TYPES }
|
||||
validates :target, presence: true
|
||||
validates :action, presence: true, inclusion: { in: RuleSet::ACTIONS }
|
||||
validates :priority, presence: true, numericality: { greater_than: 0 }
|
||||
# Validations
|
||||
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
||||
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||
validates :conditions, presence: true
|
||||
validates :enabled, inclusion: { in: [true, false] }
|
||||
|
||||
# Custom validations based on rule type
|
||||
validate :validate_conditions_by_type
|
||||
validate :validate_metadata_by_action
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :by_priority, -> { order(priority: :desc, created_at: :desc) }
|
||||
scope :expired, -> { where("expires_at < ?", Time.current) }
|
||||
scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope :disabled, -> { where(enabled: false) }
|
||||
scope :active, -> { enabled.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
||||
scope :by_type, ->(type) { where(rule_type: type) }
|
||||
scope :network_rules, -> { where(rule_type: ["network_v4", "network_v6"]) }
|
||||
scope :rate_limit_rules, -> { where(rule_type: "rate_limit") }
|
||||
scope :path_pattern_rules, -> { where(rule_type: "path_pattern") }
|
||||
scope :by_source, ->(source) { where(source: source) }
|
||||
|
||||
# Sync queries (ordered by updated_at for incremental sync)
|
||||
scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) }
|
||||
scope :sync_order, -> { order(:updated_at, :id) }
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_defaults
|
||||
before_save :calculate_priority_from_cidr
|
||||
|
||||
# Check if rule is currently active
|
||||
def active?
|
||||
enabled? && (expires_at.nil? || expires_at > Time.current)
|
||||
enabled? && !expired?
|
||||
end
|
||||
|
||||
# Check if rule matches given request context
|
||||
def matches?(context)
|
||||
return false unless active?
|
||||
|
||||
case rule_type
|
||||
when 'ip'
|
||||
match_ip_rule?(context)
|
||||
when 'cidr'
|
||||
match_cidr_rule?(context)
|
||||
when 'path'
|
||||
match_path_rule?(context)
|
||||
when 'user_agent'
|
||||
match_user_agent_rule?(context)
|
||||
when 'parameter'
|
||||
match_parameter_rule?(context)
|
||||
when 'method'
|
||||
match_method_rule?(context)
|
||||
when 'country'
|
||||
match_country_rule?(context)
|
||||
else
|
||||
false
|
||||
end
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
def to_waf_format
|
||||
# Convert to format for agent consumption
|
||||
def to_agent_format
|
||||
{
|
||||
id: id,
|
||||
type: rule_type,
|
||||
target: target,
|
||||
rule_type: rule_type,
|
||||
action: action,
|
||||
conditions: conditions || {},
|
||||
priority: priority,
|
||||
expires_at: expires_at,
|
||||
active: active?
|
||||
expires_at: expires_at&.iso8601,
|
||||
enabled: enabled,
|
||||
source: source,
|
||||
metadata: metadata || {},
|
||||
created_at: created_at.iso8601,
|
||||
updated_at: updated_at.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
# Class method to get latest version (for sync cursor)
|
||||
def self.latest_version
|
||||
maximum(:updated_at)&.iso8601(6) || Time.current.iso8601(6)
|
||||
end
|
||||
|
||||
# Disable rule (soft delete)
|
||||
def disable!(reason: nil)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: (metadata || {}).merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: reason
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Enable rule
|
||||
def enable!
|
||||
update!(enabled: true)
|
||||
end
|
||||
|
||||
# Check if this is a network rule
|
||||
def network_rule?
|
||||
rule_type.in?(%w[network_v4 network_v6])
|
||||
end
|
||||
|
||||
# Get CIDR from conditions (for network rules)
|
||||
def cidr
|
||||
conditions&.dig("cidr") if network_rule?
|
||||
end
|
||||
|
||||
# Get prefix length from CIDR
|
||||
def prefix_length
|
||||
return nil unless cidr
|
||||
cidr.split("/").last.to_i
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def match_ip_rule?(context)
|
||||
return false unless context[:ip_address]
|
||||
|
||||
target == context[:ip_address]
|
||||
def set_defaults
|
||||
self.enabled = true if enabled.nil?
|
||||
self.conditions ||= {}
|
||||
self.metadata ||= {}
|
||||
self.source ||= "manual"
|
||||
end
|
||||
|
||||
def match_cidr_rule?(context)
|
||||
return false unless context[:ip_address]
|
||||
def calculate_priority_from_cidr
|
||||
# For network rules, priority is the prefix length (more specific = higher priority)
|
||||
if network_rule? && cidr.present?
|
||||
self.priority = prefix_length
|
||||
end
|
||||
end
|
||||
|
||||
def validate_conditions_by_type
|
||||
case rule_type
|
||||
when "network_v4", "network_v6"
|
||||
validate_network_conditions
|
||||
when "rate_limit"
|
||||
validate_rate_limit_conditions
|
||||
when "path_pattern"
|
||||
validate_path_pattern_conditions
|
||||
end
|
||||
end
|
||||
|
||||
def validate_network_conditions
|
||||
cidr_value = conditions&.dig("cidr")
|
||||
|
||||
if cidr_value.blank?
|
||||
errors.add(:conditions, "must include 'cidr' for network rules")
|
||||
return
|
||||
end
|
||||
|
||||
# Validate CIDR format
|
||||
begin
|
||||
range = IPAddr.new(target)
|
||||
range.include?(context[:ip_address])
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
addr = IPAddr.new(cidr_value)
|
||||
|
||||
# Check IPv4 vs IPv6 matches rule_type
|
||||
if rule_type == "network_v4" && !addr.ipv4?
|
||||
errors.add(:conditions, "cidr must be IPv4 for network_v4 rules")
|
||||
elsif rule_type == "network_v6" && !addr.ipv6?
|
||||
errors.add(:conditions, "cidr must be IPv6 for network_v6 rules")
|
||||
end
|
||||
rescue IPAddr::InvalidAddressError => e
|
||||
errors.add(:conditions, "invalid CIDR format: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def match_path_rule?(context)
|
||||
return false unless context[:request_path]
|
||||
def validate_rate_limit_conditions
|
||||
scope = conditions&.dig("scope")
|
||||
cidr_value = conditions&.dig("cidr")
|
||||
|
||||
# Support exact match and regex patterns
|
||||
if conditions&.dig('regex') == true
|
||||
Regexp.new(target).match?(context[:request_path])
|
||||
else
|
||||
context[:request_path].start_with?(target)
|
||||
if scope.blank?
|
||||
errors.add(:conditions, "must include 'scope' for rate_limit rules")
|
||||
end
|
||||
|
||||
if cidr_value.blank?
|
||||
errors.add(:conditions, "must include 'cidr' for rate_limit rules")
|
||||
end
|
||||
|
||||
# Validate metadata has rate limit config
|
||||
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
|
||||
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules")
|
||||
end
|
||||
end
|
||||
|
||||
def match_user_agent_rule?(context)
|
||||
return false unless context[:user_agent]
|
||||
def validate_path_pattern_conditions
|
||||
patterns = conditions&.dig("patterns")
|
||||
|
||||
# Support exact match and regex patterns
|
||||
if conditions&.dig('regex') == true
|
||||
Regexp.new(target, Regexp::IGNORECASE).match?(context[:user_agent])
|
||||
else
|
||||
context[:user_agent].downcase.include?(target.downcase)
|
||||
if patterns.blank? || !patterns.is_a?(Array)
|
||||
errors.add(:conditions, "must include 'patterns' array for path_pattern rules")
|
||||
end
|
||||
end
|
||||
|
||||
def match_parameter_rule?(context)
|
||||
return false unless context[:query_params]
|
||||
|
||||
param_name = conditions&.dig('parameter_name') || target
|
||||
param_value = context[:query_params][param_name]
|
||||
|
||||
return false unless param_value
|
||||
|
||||
# Check if parameter value matches pattern
|
||||
if conditions&.dig('regex') == true
|
||||
Regexp.new(target, Regexp::IGNORECASE).match?(param_value.to_s)
|
||||
else
|
||||
param_value.to_s.downcase.include?(target.downcase)
|
||||
def validate_metadata_by_action
|
||||
case action
|
||||
when "redirect"
|
||||
unless metadata&.dig("redirect_url").present?
|
||||
errors.add(:metadata, "must include 'redirect_url' for redirect action")
|
||||
end
|
||||
when "rate_limit"
|
||||
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
|
||||
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit action")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def match_method_rule?(context)
|
||||
return false unless context[:request_method]
|
||||
|
||||
target.upcase == context[:request_method].upcase
|
||||
end
|
||||
|
||||
def match_country_rule?(context)
|
||||
return false unless context[:country_code]
|
||||
|
||||
target.upcase == context[:country_code].upcase
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,7 +47,7 @@ class EventNormalizer
|
||||
else :allow
|
||||
end
|
||||
|
||||
@event.action = action_enum
|
||||
@event.waf_action = action_enum
|
||||
end
|
||||
|
||||
def normalize_method
|
||||
|
||||
174
app/services/geo_ip_service.rb
Normal file
174
app/services/geo_ip_service.rb
Normal file
@@ -0,0 +1,174 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'maxmind/db'
|
||||
require 'httparty'
|
||||
require 'digest'
|
||||
require 'tmpdir'
|
||||
require 'fileutils'
|
||||
|
||||
class GeoIpService
|
||||
include HTTParty
|
||||
|
||||
class DatabaseNotAvailable < StandardError; end
|
||||
class InvalidIpAddress < StandardError; end
|
||||
|
||||
attr_reader :database_reader, :database_path
|
||||
|
||||
def initialize(database_path: nil)
|
||||
@database_path = database_path || default_database_path
|
||||
@database_reader = nil
|
||||
load_database if File.exist?(@database_path)
|
||||
end
|
||||
|
||||
# Main lookup method - returns country code for IP address
|
||||
def lookup_country(ip_address)
|
||||
return fallback_country unless database_available?
|
||||
return fallback_country unless valid_ip?(ip_address)
|
||||
|
||||
result = database_reader.get(ip_address)
|
||||
return fallback_country if result.nil? || result.empty?
|
||||
|
||||
# Extract country code from MaxMind result
|
||||
result['country']&.[]('iso_code') || fallback_country
|
||||
rescue => e
|
||||
Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}"
|
||||
fallback_country
|
||||
end
|
||||
|
||||
# Check if database is available and loaded
|
||||
def database_available?
|
||||
return false unless File.exist?(@database_path)
|
||||
|
||||
load_database unless database_reader
|
||||
database_reader.present?
|
||||
end
|
||||
|
||||
# Get database information
|
||||
def database_info
|
||||
return nil unless File.exist?(@database_path)
|
||||
|
||||
file_stat = File.stat(@database_path)
|
||||
metadata = database_reader&.metadata
|
||||
|
||||
if metadata
|
||||
{
|
||||
type: 'GeoLite2-Country',
|
||||
version: "#{metadata.binary_format_major_version}.#{metadata.binary_format_minor_version}",
|
||||
size: file_stat.size,
|
||||
modified_at: file_stat.mtime,
|
||||
age_days: ((Time.current - file_stat.mtime) / 1.day).round,
|
||||
file_path: @database_path
|
||||
}
|
||||
else
|
||||
{
|
||||
type: 'GeoLite2-Country',
|
||||
version: 'Unknown',
|
||||
size: file_stat.size,
|
||||
modified_at: file_stat.mtime,
|
||||
age_days: ((Time.current - file_stat.mtime) / 1.day).round,
|
||||
file_path: @database_path
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Class method for convenience lookup
|
||||
def self.lookup_country(ip_address)
|
||||
new.lookup_country(ip_address)
|
||||
end
|
||||
|
||||
# Update database from remote source
|
||||
def self.update_database!
|
||||
new.update_from_remote!
|
||||
end
|
||||
|
||||
# Download and install database from remote URL
|
||||
def update_from_remote!
|
||||
config = Rails.application.config.maxmind
|
||||
database_url = config.database_url
|
||||
storage_path = config.storage_path
|
||||
database_filename = config.database_filename
|
||||
temp_file = nil
|
||||
|
||||
Rails.logger.info "Starting GeoIP database download from #{database_url}"
|
||||
|
||||
begin
|
||||
# Ensure storage directory exists
|
||||
FileUtils.mkdir_p(storage_path) unless Dir.exist?(storage_path)
|
||||
|
||||
# Download to temporary file
|
||||
Dir.mktmpdir do |temp_dir|
|
||||
temp_file = File.join(temp_dir, database_filename)
|
||||
|
||||
response = HTTParty.get(database_url, timeout: 60)
|
||||
raise "Failed to download database: #{response.code}" unless response.success?
|
||||
|
||||
File.binwrite(temp_file, response.body)
|
||||
|
||||
# Validate downloaded file
|
||||
validate_downloaded_file(temp_file)
|
||||
|
||||
# Move to final location
|
||||
final_path = File.join(storage_path, database_filename)
|
||||
File.rename(temp_file, final_path)
|
||||
|
||||
# Reload the database with new file
|
||||
@database_reader = nil
|
||||
load_database
|
||||
|
||||
Rails.logger.info "GeoIP database successfully updated: #{final_path}"
|
||||
return true
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to update GeoIP database: #{e.message}"
|
||||
File.delete(temp_file) if temp_file && File.exist?(temp_file)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_database
|
||||
return unless File.exist?(@database_path)
|
||||
|
||||
@database_reader = MaxMind::DB.new(@database_path)
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to load GeoIP database: #{e.message}"
|
||||
@database_reader = nil
|
||||
end
|
||||
|
||||
def default_database_path
|
||||
config = Rails.application.config.maxmind
|
||||
File.join(config.storage_path, config.database_filename)
|
||||
end
|
||||
|
||||
def valid_ip?(ip_address)
|
||||
IPAddr.new(ip_address)
|
||||
true
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
def fallback_country
|
||||
config = Rails.application.config.maxmind
|
||||
return nil unless config.enable_fallback
|
||||
config.fallback_country
|
||||
end
|
||||
|
||||
def cache_size
|
||||
return 0 unless Rails.application.config.maxmind.cache_enabled
|
||||
Rails.application.config.maxmind.cache_size
|
||||
end
|
||||
|
||||
def validate_downloaded_file(file_path)
|
||||
# Basic file existence and size check
|
||||
raise "Downloaded file is empty" unless File.exist?(file_path)
|
||||
raise "Downloaded file is too small" if File.size(file_path) < 1_000_000 # ~1MB minimum
|
||||
|
||||
# Try to open with MaxMind reader
|
||||
begin
|
||||
MaxMind::DB.new(file_path)
|
||||
rescue => e
|
||||
raise "Invalid MaxMind database format: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
78
app/services/hub_load.rb
Normal file
78
app/services/hub_load.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# HubLoad - Calculates dynamic event sampling rate based on system load
|
||||
#
|
||||
# This service monitors SolidQueue depth and adjusts sampling rates to prevent
|
||||
# the Hub from being overwhelmed while ensuring critical events are always captured.
|
||||
class HubLoad
|
||||
# Queue depth thresholds
|
||||
THRESHOLDS = {
|
||||
normal: 0..1_000, # 100% sampling
|
||||
moderate: 1_001..5_000, # 50% sampling
|
||||
high: 5_001..10_000, # 20% sampling
|
||||
critical: 10_001..Float::INFINITY # 5% sampling
|
||||
}.freeze
|
||||
|
||||
SAMPLING_RATES = {
|
||||
normal: { allowed: 1.0, blocked: 1.0, rate_limited: 1.0 },
|
||||
moderate: { allowed: 0.5, blocked: 1.0, rate_limited: 1.0 },
|
||||
high: { allowed: 0.2, blocked: 1.0, rate_limited: 1.0 },
|
||||
critical: { allowed: 0.05, blocked: 1.0, rate_limited: 1.0 }
|
||||
}.freeze
|
||||
|
||||
# Get current sampling configuration based on load
|
||||
def self.current_sampling
|
||||
load_level = calculate_load_level
|
||||
rates = SAMPLING_RATES[load_level]
|
||||
|
||||
{
|
||||
allowed_requests: rates[:allowed],
|
||||
blocked_requests: rates[:blocked],
|
||||
rate_limited_requests: rates[:rate_limited],
|
||||
effective_until: next_sync_time,
|
||||
load_level: load_level,
|
||||
queue_depth: queue_depth
|
||||
}
|
||||
end
|
||||
|
||||
# Calculate when sampling should be rechecked (next agent sync)
|
||||
def self.next_sync_time
|
||||
10.seconds.from_now.iso8601(3)
|
||||
end
|
||||
|
||||
# Get current queue depth
|
||||
def self.queue_depth
|
||||
# SolidQueue stores jobs in the jobs table
|
||||
# Count pending/running jobs only
|
||||
SolidQueue::Job.where(finished_at: nil).count
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to get queue depth: #{e.message}"
|
||||
0
|
||||
end
|
||||
|
||||
# Determine load level based on queue depth
|
||||
def self.calculate_load_level
|
||||
depth = queue_depth
|
||||
|
||||
THRESHOLDS.each do |level, range|
|
||||
return level if range.cover?(depth)
|
||||
end
|
||||
|
||||
:critical # Fallback
|
||||
end
|
||||
|
||||
# Check if hub is under heavy load
|
||||
def self.overloaded?
|
||||
calculate_load_level.in?([:high, :critical])
|
||||
end
|
||||
|
||||
# Get load statistics for monitoring
|
||||
def self.stats
|
||||
{
|
||||
queue_depth: queue_depth,
|
||||
load_level: calculate_load_level,
|
||||
sampling_rates: SAMPLING_RATES[calculate_load_level],
|
||||
overloaded: overloaded?
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<%= link_to "View", project_path(project), class: "btn btn-primary btn-sm" %>
|
||||
<%= link_to "Events", events_project_path(project), class: "btn btn-secondary btn-sm" %>
|
||||
<%= link_to "Events", project_events_path(project), class: "btn btn-secondary btn-sm" %>
|
||||
<%= link_to "Analytics", analytics_project_path(project), class: "btn btn-info btn-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user