340 lines
12 KiB
Ruby
340 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# NetworkRangesController - Browse and manage network ranges
|
|
#
|
|
# Provides interface for viewing, searching, and managing network ranges
|
|
# with their intelligence data and associated rules.
|
|
class NetworkRangesController < ApplicationController
|
|
# Follow proper before_action order:
|
|
# 1. Authentication/Authorization
|
|
# All actions require authentication
|
|
|
|
# 2. Resource loading
|
|
before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich]
|
|
|
|
# GET /network_ranges
|
|
def index
|
|
# Start with base scope
|
|
base_scope = policy_scope(NetworkRange.includes(:rules)).order(updated_at: :desc)
|
|
|
|
# Apply filters BEFORE pagination
|
|
base_scope = apply_filters(base_scope)
|
|
|
|
# Apply search BEFORE pagination
|
|
if params[:search].present?
|
|
base_scope = search_network_ranges(base_scope, params[:search])
|
|
end
|
|
|
|
# Apply pagination to the filtered scope
|
|
@pagy, @network_ranges = pagy(base_scope)
|
|
|
|
# Statistics
|
|
@total_ranges = NetworkRange.count
|
|
@ranges_with_intelligence = NetworkRange.where.not(asn: nil).or(NetworkRange.where.not(company: nil)).count
|
|
@datacenter_ranges = NetworkRange.where(is_datacenter: true).count
|
|
@vpn_ranges = NetworkRange.where(is_vpn: true).count
|
|
@proxy_ranges = NetworkRange.where(is_proxy: true).count
|
|
|
|
# Top countries, companies, ASNs
|
|
@top_countries = NetworkRange.where.not(country: nil).group(:country).count.sort_by { |_, c| -c }.first(10)
|
|
@top_companies = NetworkRange.where.not(company: nil).group(:company).count.sort_by { |_, c| -c }.first(10)
|
|
@top_asns = NetworkRange.where.not(asn: nil).group(:asn, :asn_org).count.sort_by { |_, c| -c }.first(10)
|
|
end
|
|
|
|
# GET /network_ranges/:id
|
|
def show
|
|
authorize @network_range
|
|
|
|
if @network_range.persisted?
|
|
# Real network - use indexed network_range_id for much better performance
|
|
# Include child network ranges to capture all traffic within this network block
|
|
network_ids = [@network_range.id] + @network_range.child_ranges.pluck(:id)
|
|
events_scope = Event.where(network_range_id: network_ids).recent
|
|
else
|
|
# Virtual network - find events by IP range containment
|
|
events_scope = Event.where("ip_address <<= ?::inet", @network_range.to_s).recent
|
|
end
|
|
|
|
# Paginate events
|
|
@events_pagy, @related_events = pagy(events_scope, items: 50)
|
|
|
|
@child_ranges = @network_range.child_ranges.limit(20)
|
|
@parent_ranges = @network_range.parent_ranges.limit(10)
|
|
@associated_rules = @network_range.persisted? ? @network_range.rules.includes(:user, :network_range, :waf_policy).order(created_at: :desc) : []
|
|
|
|
# Load rules from supernets and subnets
|
|
@supernet_rules = @network_range.persisted? ? @network_range.supernet_rules.includes(:network_range, :user, :waf_policy).limit(10) : []
|
|
@subnet_rules = @network_range.persisted? ? @network_range.child_rules.includes(:network_range, :user, :waf_policy).limit(20) : []
|
|
|
|
# Traffic analytics (if we have events)
|
|
@traffic_stats = calculate_traffic_stats(@network_range)
|
|
|
|
# Check if we have IPAPI data (or if parent has it) - cache expensive parent lookup
|
|
@has_ipapi_data = @network_range.has_network_data_from?(:ipapi)
|
|
@parent_with_ipapi = nil
|
|
|
|
unless @has_ipapi_data
|
|
# Cache expensive parent intelligence lookup
|
|
parent = Rails.cache.fetch("network_parent_intel:#{@network_range.cache_key}", expires_in: 1.hour) do
|
|
@network_range.parent_with_intelligence
|
|
end
|
|
if parent&.has_network_data_from?(:ipapi)
|
|
@parent_with_ipapi = parent
|
|
@has_ipapi_data = true
|
|
end
|
|
end
|
|
|
|
# If we don't have IPAPI data anywhere and no parent has it, queue fetch job
|
|
if @network_range.persisted? && @network_range.should_fetch_ipapi_data?
|
|
@network_range.mark_as_fetching_api_data!(:ipapi)
|
|
FetchIpapiDataJob.perform_later(network_range_id: @network_range.id)
|
|
@ipapi_loading = true
|
|
end
|
|
|
|
# Get IPAPI data for display
|
|
@ipapi_data = if @parent_with_ipapi
|
|
@parent_with_ipapi.network_data_for(:ipapi)
|
|
elsif @network_range.has_network_data_from?(:ipapi)
|
|
@network_range.network_data_for(:ipapi)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
# GET /network_ranges/new
|
|
def new
|
|
authorize NetworkRange
|
|
@network_range = NetworkRange.new(network: params[:network])
|
|
end
|
|
|
|
# POST /network_ranges
|
|
def create
|
|
authorize NetworkRange
|
|
@network_range = NetworkRange.new(network_range_params)
|
|
@network_range.user = Current.user
|
|
@network_range.source = 'user_created'
|
|
|
|
respond_to do |format|
|
|
if @network_range.save
|
|
format.html { redirect_to @network_range, notice: 'Network range was successfully created.' }
|
|
format.json { render json: @network_range.as_json(only: [:id, :network, :company, :asn, :asn_org, :country, :is_datacenter, :is_vpn, :is_proxy]) }
|
|
else
|
|
format.html { render :new, status: :unprocessable_entity }
|
|
format.json { render json: { error: @network_range.errors.full_messages.join(', ') }, status: :unprocessable_entity }
|
|
end
|
|
end
|
|
end
|
|
|
|
# GET /network_ranges/:id/edit
|
|
def edit
|
|
authorize @network_range
|
|
end
|
|
|
|
# PATCH/PUT /network_ranges/:id
|
|
def update
|
|
authorize @network_range
|
|
if @network_range.update(network_range_params)
|
|
redirect_to @network_range, notice: 'Network range was successfully updated.'
|
|
else
|
|
render :edit, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
# DELETE /network_ranges/:id
|
|
def destroy
|
|
authorize @network_range
|
|
@network_range.destroy
|
|
redirect_to network_ranges_url, notice: 'Network range was successfully deleted.'
|
|
end
|
|
|
|
# POST /network_ranges/:id/enrich
|
|
def enrich
|
|
authorize @network_range, :enrich?
|
|
# Attempt to enrich this network range with API data
|
|
# This would integrate with external IP intelligence services
|
|
enrichment_service = NetworkEnrichmentService.new(@network_range)
|
|
result = enrichment_service.enrich!
|
|
|
|
if result[:success]
|
|
redirect_to @network_range, notice: "Network range enriched with #{result[:fields_added]} new fields."
|
|
else
|
|
redirect_to @network_range, alert: "Failed to enrich network range: #{result[:error]}"
|
|
end
|
|
end
|
|
|
|
# GET /network_ranges/lookup
|
|
def lookup
|
|
authorize NetworkRange, :lookup?
|
|
ip_address = params[:ip]
|
|
return render json: { error: 'IP address required' }, status: :bad_request if ip_address.blank?
|
|
|
|
@ranges = NetworkRange.contains_ip(ip_address).includes(:rules)
|
|
@ip_intelligence = IpRangeResolver.get_ip_intelligence(ip_address)
|
|
@suggested_blocks = IpRangeResolver.suggest_blocking_ranges(ip_address)
|
|
|
|
render :lookup
|
|
end
|
|
|
|
# GET /network_ranges/search
|
|
def search
|
|
authorize NetworkRange, :index?
|
|
query = params[:q]
|
|
|
|
if query.blank?
|
|
render json: []
|
|
return
|
|
end
|
|
|
|
# Search by network CIDR (cast inet to text for ILIKE), company, ASN org, or country
|
|
@network_ranges = NetworkRange.where(
|
|
"network::text ILIKE ? OR company ILIKE ? OR asn_org ILIKE ? OR country ILIKE ? OR asn::text ILIKE ?",
|
|
"%#{query}%", "%#{query}%", "%#{query}%", "%#{query}%", "%#{query}%"
|
|
).limit(20)
|
|
|
|
render json: @network_ranges.as_json(
|
|
only: [:id, :network, :company, :asn, :asn_org, :country, :is_datacenter, :is_vpn, :is_proxy]
|
|
)
|
|
end
|
|
|
|
private
|
|
|
|
# Helper method to try DuckDB first, fall back to PostgreSQL
|
|
def with_duckdb_fallback(&block)
|
|
result = yield
|
|
result.nil? ? nil : result # Return result or nil to trigger fallback
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[NetworkRanges] DuckDB query failed, falling back to PostgreSQL: #{e.message}"
|
|
nil # Return nil to trigger fallback
|
|
end
|
|
|
|
def set_network_range
|
|
# Handle CIDR slugs (e.g., "40.77.167.100_32" -> "40.77.167.100/32")
|
|
cidr = params[:id].gsub('_', '/')
|
|
@network_range = NetworkRange.find_by(network: cidr)
|
|
|
|
# If network doesn't exist, create a virtual (unsaved) instance
|
|
if @network_range.nil?
|
|
@network_range = NetworkRange.new(network: cidr)
|
|
end
|
|
end
|
|
|
|
def network_range_params
|
|
params.require(:network_range).permit(
|
|
:network,
|
|
:source,
|
|
:creation_reason,
|
|
:asn,
|
|
:asn_org,
|
|
:company,
|
|
:country,
|
|
:is_datacenter,
|
|
:is_proxy,
|
|
:is_vpn,
|
|
:abuser_scores,
|
|
:additional_data
|
|
)
|
|
end
|
|
|
|
def apply_filters(scope)
|
|
scope = scope.where(country: params[:country]) if params[:country].present?
|
|
scope = scope.where(company: params[:company]) if params[:company].present?
|
|
scope = scope.where(asn: params[:asn].to_i) if params[:asn].present?
|
|
scope = scope.where(is_datacenter: true) if params[:datacenter] == 'true'
|
|
scope = scope.where(is_vpn: true) if params[:vpn] == 'true'
|
|
scope = scope.where(is_proxy: true) if params[:proxy] == 'true'
|
|
scope = scope.where(source: params[:source]) if params[:source].present?
|
|
scope
|
|
end
|
|
|
|
def search_network_ranges(scope, search_term)
|
|
# Search by network CIDR, company, ASN, or country
|
|
scope.where(
|
|
"network ILIKE ? OR company ILIKE ? OR asn_org ILIKE ? OR country ILIKE ?",
|
|
"%#{search_term}%", "%#{search_term}%", "%#{search_term}%", "%#{search_term}%"
|
|
)
|
|
end
|
|
|
|
def calculate_traffic_stats(network_range)
|
|
if network_range.persisted?
|
|
# Real network - check if network has events using DuckDB for performance
|
|
if network_range.has_events?
|
|
# Use indexed network_range_id for much better performance instead of expensive CIDR operator
|
|
# Include child network ranges to capture all traffic within this network block
|
|
network_ids = [network_range.id] + network_range.child_ranges.pluck(:id)
|
|
|
|
# Try DuckLake first for stats (much faster)
|
|
duckdb_stats = with_duckdb_fallback { BaffleDl.network_traffic_stats(network_ids) }
|
|
duckdb_top_paths = with_duckdb_fallback { BaffleDl.network_top_paths(network_ids, 10) }
|
|
duckdb_top_agents = with_duckdb_fallback { BaffleDl.network_top_user_agents(network_ids, 5) }
|
|
|
|
if duckdb_stats
|
|
# DuckDB success - use fast aggregated stats
|
|
stats = duckdb_stats.merge(
|
|
top_paths: duckdb_top_paths&.to_h || {},
|
|
top_user_agents: duckdb_top_agents&.to_h || {},
|
|
recent_activity: Event.where(network_range_id: network_ids).recent.limit(20)
|
|
)
|
|
else
|
|
# PostgreSQL fallback
|
|
base_query = Event.where(network_range_id: network_ids)
|
|
events_for_grouping = base_query.limit(1000)
|
|
events_for_activity = base_query.recent.limit(20)
|
|
|
|
stats = {
|
|
total_requests: base_query.count,
|
|
unique_ips: base_query.except(:order).distinct.count(:ip_address),
|
|
blocked_requests: base_query.blocked.count,
|
|
allowed_requests: base_query.allowed.count,
|
|
top_paths: events_for_grouping.group(:request_path).count.sort_by { |_, count| -count }.first(10).to_h,
|
|
top_user_agents: events_for_grouping.group(:user_agent).count.sort_by { |_, count| -count }.first(5).to_h,
|
|
recent_activity: events_for_activity
|
|
}
|
|
end
|
|
|
|
stats
|
|
else
|
|
# No events - return empty stats
|
|
{
|
|
total_requests: 0,
|
|
unique_ips: 0,
|
|
blocked_requests: 0,
|
|
allowed_requests: 0,
|
|
top_paths: {},
|
|
top_user_agents: {},
|
|
recent_activity: []
|
|
}
|
|
end
|
|
else
|
|
# Virtual network - calculate stats from events within range
|
|
base_query = Event.where("ip_address <<= ?", network_range.cidr)
|
|
total_events = base_query.count
|
|
|
|
if total_events > 0
|
|
# Use separate queries: one for grouping (without ordering), one for recent activity (with ordering)
|
|
events_for_grouping = base_query.limit(1000)
|
|
events_for_activity = base_query.recent.limit(20)
|
|
|
|
{
|
|
total_requests: total_events,
|
|
unique_ips: base_query.except(:order).distinct.count(:ip_address),
|
|
blocked_requests: base_query.blocked.count,
|
|
allowed_requests: base_query.allowed.count,
|
|
top_paths: events_for_grouping.group(:request_path).count.sort_by { |_, count| -count }.first(10).to_h,
|
|
top_user_agents: events_for_grouping.group(:user_agent).count.sort_by { |_, count| -count }.first(5).to_h,
|
|
recent_activity: events_for_activity
|
|
}
|
|
else
|
|
# No events for virtual network
|
|
{
|
|
total_requests: 0,
|
|
unique_ips: 0,
|
|
blocked_requests: 0,
|
|
allowed_requests: 0,
|
|
top_paths: {},
|
|
top_user_agents: {},
|
|
recent_activity: []
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end |