Migrate to Postgresql for better network handling. Add more user functionality.
This commit is contained in:
290
app/models/network_range.rb
Normal file
290
app/models/network_range.rb
Normal file
@@ -0,0 +1,290 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# NetworkRange - Unified IPv4/IPv6 network range management
|
||||
#
|
||||
# Uses PostgreSQL's inet type to handle both IPv4 and IPv4 networks seamlessly.
|
||||
# Provides network intelligence data including ASN, company, geographic info,
|
||||
# and classification flags (datacenter, proxy, VPN).
|
||||
class NetworkRange < ApplicationRecord
|
||||
# Sources for network range creation
|
||||
SOURCES = %w[api_imported user_created manual auto_generated inherited].freeze
|
||||
|
||||
# Associations
|
||||
has_many :rules, dependent: :destroy
|
||||
belongs_to :user, optional: true
|
||||
|
||||
# Validations
|
||||
validates :network, presence: true, uniqueness: true
|
||||
validates :source, inclusion: { in: SOURCES }
|
||||
validates :asn, numericality: { greater_than: 0 }, allow_blank: true
|
||||
|
||||
# Scopes
|
||||
scope :ipv4, -> { where("family(network) = 4") }
|
||||
scope :ipv6, -> { where("family(network) = 6") }
|
||||
scope :by_country, ->(country) { where(country: country) }
|
||||
scope :by_company, ->(company) { where(company: company) }
|
||||
scope :by_asn, ->(asn) { where(asn: asn) }
|
||||
scope :datacenter, -> { where(is_datacenter: true) }
|
||||
scope :proxy, -> { where(is_proxy: true) }
|
||||
scope :vpn, -> { where(is_vpn: true) }
|
||||
scope :user_created, -> { where(source: 'user_created') }
|
||||
scope :api_imported, -> { where(source: 'api_imported') }
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_default_source
|
||||
# after_save :update_children_inheritance!, if: :should_update_children_inheritance? # Disabled for now
|
||||
|
||||
# Virtual attribute for CIDR notation
|
||||
def cidr
|
||||
network.to_s
|
||||
end
|
||||
|
||||
def cidr=(new_cidr)
|
||||
self.network = new_cidr
|
||||
end
|
||||
|
||||
# Network properties
|
||||
def prefix_length
|
||||
# Get prefix length from IPAddr object
|
||||
network.prefix
|
||||
end
|
||||
|
||||
def network_address
|
||||
# Use PostgreSQL's host function or get from IPAddr object
|
||||
network.to_s
|
||||
end
|
||||
|
||||
def cidr
|
||||
# Return full CIDR notation
|
||||
"#{network_address}/#{prefix_length}"
|
||||
end
|
||||
|
||||
def broadcast_address
|
||||
# Use PostgreSQL's broadcast function
|
||||
result = self.class.connection.execute("SELECT broadcast('#{network.to_s}')").first
|
||||
result&.values&.first
|
||||
end
|
||||
|
||||
def family
|
||||
# Check if it's IPv4 or IPv6 by looking at the address
|
||||
addr = network.to_s.split('/').first
|
||||
addr.include?(':') ? 6 : 4
|
||||
end
|
||||
|
||||
def ipv4?
|
||||
family == 4
|
||||
end
|
||||
|
||||
def ipv6?
|
||||
family == 6
|
||||
end
|
||||
|
||||
# Network containment and overlap operations
|
||||
def contains_ip?(ip_string)
|
||||
# Use Postgres >>= operator for containment
|
||||
self.class.where("network >>= ?::inet", ip_string).exists?
|
||||
rescue => e
|
||||
Rails.logger.error "Error checking IP containment: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def contains_network?(other_cidr)
|
||||
other_network = IPAddr.new(other_cidr)
|
||||
network_range = IPAddr.new(network)
|
||||
network_range.include?(other_network)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
def overlaps?(other_cidr)
|
||||
network_range = IPAddr.new(network)
|
||||
other_network = IPAddr.new(other_cidr)
|
||||
network_range.include?(other_network) || other_network.include?(network_range)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
# Parent/child relationships
|
||||
def parent_ranges
|
||||
NetworkRange.where("network << ?::inet AND masklen(network) < ?", network.to_s, prefix_length)
|
||||
.order("masklen(network) DESC")
|
||||
end
|
||||
|
||||
def child_ranges
|
||||
NetworkRange.where("network >> ?::inet AND masklen(network) > ?", network.to_s, prefix_length)
|
||||
.order("masklen(network) ASC")
|
||||
end
|
||||
|
||||
def sibling_ranges
|
||||
NetworkRange.where("masklen(network) = ?", prefix_length)
|
||||
.where("network && ?::inet", network.to_s)
|
||||
.where.not(id: id)
|
||||
end
|
||||
|
||||
# Find nearest parent with intelligence data
|
||||
def parent_with_intelligence
|
||||
# Use Postgres network operators to find parent ranges directly
|
||||
cidr_str = network.to_s
|
||||
if cidr_str.include?('/')
|
||||
addr_parts = network_address.split('.')
|
||||
case addr_parts.length
|
||||
when 4 # IPv4
|
||||
new_prefix = [prefix_length - 8, 16].max
|
||||
parent_cidr = "#{addr_parts[0]}.#{addr_parts[1]}.#{addr_parts[2]}.0/#{new_prefix}"
|
||||
else # IPv6 - skip for now
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
return nil unless parent_cidr
|
||||
|
||||
NetworkRange.where("network <<= ?::inet AND masklen(network) < ?", parent_cidr, prefix_length)
|
||||
.where.not(asn: nil)
|
||||
.order("masklen(network) DESC")
|
||||
.first
|
||||
end
|
||||
|
||||
def inherited_intelligence
|
||||
return own_intelligence if has_intelligence?
|
||||
|
||||
parent = parent_with_intelligence
|
||||
parent ? parent.own_intelligence.merge(inherited: true, parent_cidr: parent.cidr) : {}
|
||||
end
|
||||
|
||||
def has_intelligence?
|
||||
asn.present? || company.present? || country.present? ||
|
||||
is_datacenter? || is_proxy? || is_vpn?
|
||||
end
|
||||
|
||||
def own_intelligence
|
||||
{
|
||||
asn: asn,
|
||||
asn_org: asn_org,
|
||||
company: company,
|
||||
country: country,
|
||||
is_datacenter: is_datacenter,
|
||||
is_proxy: is_proxy,
|
||||
is_vpn: is_vpn,
|
||||
inherited: false,
|
||||
source: source
|
||||
}
|
||||
end
|
||||
|
||||
# Geographic lookup
|
||||
def geo_lookup_country!
|
||||
return if country.present?
|
||||
|
||||
sample_ip = network_address
|
||||
geo_country = GeoIpService.lookup_country(sample_ip)
|
||||
update!(country: geo_country) if geo_country.present?
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to lookup geo location for network range #{cidr}: #{e.message}"
|
||||
end
|
||||
|
||||
# Class methods for network operations
|
||||
def self.contains_ip(ip_string)
|
||||
where("network >>= ?", ip_string)
|
||||
.order("masklen(network) DESC") # Most specific first
|
||||
end
|
||||
|
||||
def self.overlapping(range_cidr)
|
||||
where("network && ?", range_cidr)
|
||||
end
|
||||
|
||||
def self.find_or_create_by_cidr(cidr, user: nil, source: nil, reason: nil)
|
||||
find_or_create_by(network: cidr) do |range|
|
||||
range.user = user
|
||||
range.source = source || 'user_created'
|
||||
range.creation_reason = reason
|
||||
end
|
||||
end
|
||||
|
||||
def self.import_from_cidr(cidr, **attributes)
|
||||
find_or_create_by(network: cidr) do |range|
|
||||
range.assign_attributes(attributes)
|
||||
end
|
||||
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
|
||||
|
||||
# String representations
|
||||
def to_s
|
||||
cidr
|
||||
end
|
||||
|
||||
def to_param
|
||||
cidr.to_s.gsub('/', '_')
|
||||
end
|
||||
|
||||
# Analytics methods
|
||||
def events_count
|
||||
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]).count
|
||||
end
|
||||
|
||||
def recent_events(limit: 100)
|
||||
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address])
|
||||
.recent
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
def blocking_rules
|
||||
rules.where(action: 'deny', enabled: true)
|
||||
end
|
||||
|
||||
def active_rules
|
||||
rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_source
|
||||
self.source ||= 'api_imported'
|
||||
end
|
||||
|
||||
def should_update_children_inheritance?
|
||||
saved_change_to_attribute?(:asn) ||
|
||||
saved_change_to_attribute?(:company) ||
|
||||
saved_change_to_attribute?(:country) ||
|
||||
saved_change_to_attribute?(:is_datacenter) ||
|
||||
saved_change_to_attribute?(:is_proxy) ||
|
||||
saved_change_to_attribute?(:is_vpn)
|
||||
end
|
||||
|
||||
def update_children_inheritance!
|
||||
# Find child ranges that don't have their own intelligence
|
||||
child_without_intelligence = child_ranges.where(
|
||||
asn: nil,
|
||||
company: nil,
|
||||
country: nil,
|
||||
is_datacenter: false,
|
||||
is_proxy: false,
|
||||
is_vpn: false
|
||||
)
|
||||
|
||||
child_without_intelligence.find_each do |child|
|
||||
Rails.logger.info "Child range #{child.cidr} can now inherit from parent #{cidr}"
|
||||
# The inherited_intelligence method will pick up the new parent data
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user