# 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 - use cached events_count for total requests (much more performant) if network_range.events_count > 0 # 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 DuckDB first for stats (much faster) duckdb_stats = with_duckdb_fallback { EventDdb.network_traffic_stats(network_ids) } duckdb_top_paths = with_duckdb_fallback { EventDdb.network_top_paths(network_ids, 10) } duckdb_top_agents = with_duckdb_fallback { EventDdb.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