Migrate to Postgresql for better network handling. Add more user functionality.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
class Api::EventsController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
|
||||
|
||||
# POST /api/:project_id/events
|
||||
def create
|
||||
@@ -27,8 +28,8 @@ class Api::EventsController < ApplicationController
|
||||
response.headers['X-Sample-Rate'] = current_sampling[:allowed_requests].to_s
|
||||
response.headers['X-Sample-Until'] = current_sampling[:effective_until]
|
||||
|
||||
# Check if agent sent a rule version to compare against
|
||||
client_version = request.headers['X-Rule-Version']&.to_i
|
||||
# Check if agent sent a rule version in the JSON body to compare against
|
||||
client_version = event_data.dig('last_rule_sync')&.to_i
|
||||
|
||||
response_data = {
|
||||
success: true,
|
||||
@@ -40,8 +41,7 @@ class Api::EventsController < ApplicationController
|
||||
if client_version.blank? || client_version != rule_version
|
||||
# Get rules updated since client version
|
||||
if client_version.present?
|
||||
since_time = Time.at(client_version / 1_000_000, client_version % 1_000_000)
|
||||
rules = Rule.where("updated_at > ?", since_time).enabled.sync_order
|
||||
rules = Rule.since(client_version).enabled
|
||||
else
|
||||
# Full sync for new agents
|
||||
rules = Rule.active.sync_order
|
||||
|
||||
@@ -7,6 +7,7 @@ module Api
|
||||
# These endpoints are kept for administrative/debugging purposes only
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
allow_unauthenticated_access # Skip normal session auth, use project key auth instead
|
||||
before_action :authenticate_project!
|
||||
before_action :check_project_enabled
|
||||
|
||||
@@ -23,8 +24,8 @@ module Api
|
||||
}
|
||||
end
|
||||
|
||||
# GET /api/:public_key/rules?since=1730646186272060
|
||||
# Incremental sync - returns rules updated since timestamp (microsecond Unix timestamp)
|
||||
# GET /api/:public_key/rules?since=1730646186
|
||||
# Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds)
|
||||
# GET /api/:public_key/rules
|
||||
# Full sync - returns all active rules
|
||||
def index
|
||||
@@ -69,17 +70,14 @@ module Api
|
||||
end
|
||||
|
||||
def parse_timestamp(timestamp_str)
|
||||
# Parse microsecond Unix timestamp
|
||||
# Parse Unix timestamp in seconds
|
||||
unless timestamp_str.match?(/^\d+$/)
|
||||
raise ArgumentError, "Invalid timestamp format. Expected microsecond Unix timestamp (e.g., 1730646186272060)"
|
||||
raise ArgumentError, "Invalid timestamp format. Expected Unix timestamp in seconds (e.g., 1730646186)"
|
||||
end
|
||||
|
||||
total_microseconds = timestamp_str.to_i
|
||||
seconds = total_microseconds / 1_000_000
|
||||
microseconds = total_microseconds % 1_000_000
|
||||
Time.at(seconds, microseconds)
|
||||
Time.at(timestamp_str.to_i)
|
||||
rescue ArgumentError => e
|
||||
raise ArgumentError, "Invalid timestamp format: #{e.message}. Use microsecond Unix timestamp (e.g., 1730646186272060)"
|
||||
raise ArgumentError, "Invalid timestamp format: #{e.message}. Use Unix timestamp in seconds (e.g., 1730646186)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,9 +7,13 @@ class ApplicationController < ActionController::Base
|
||||
stale_when_importmap_changes
|
||||
|
||||
include Pagy::Backend
|
||||
include Pagy::Frontend
|
||||
include Pundit::Authorization
|
||||
|
||||
helper_method :current_user, :user_signed_in?, :current_user_admin?, :current_user_viewer?
|
||||
|
||||
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
||||
|
||||
private
|
||||
|
||||
def current_user
|
||||
@@ -43,4 +47,12 @@ class ApplicationController < ActionController::Base
|
||||
def after_authentication_url
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
end
|
||||
|
||||
def user_not_authorized
|
||||
if user_signed_in?
|
||||
redirect_to root_path, alert: "You don't have permission to perform this action."
|
||||
else
|
||||
redirect_to new_session_path, alert: "Please sign in to continue."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
221
app/controllers/network_ranges_controller.rb
Normal file
221
app/controllers/network_ranges_controller.rb
Normal file
@@ -0,0 +1,221 @@
|
||||
# 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
|
||||
allow_unauthenticated_access only: [:index, :show, :lookup]
|
||||
|
||||
# 2. Resource loading
|
||||
before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich]
|
||||
before_action :set_project, only: [:index, :show]
|
||||
|
||||
# GET /network_ranges
|
||||
def index
|
||||
@pagy, @network_ranges = pagy(policy_scope(NetworkRange.includes(:rules))
|
||||
.order(updated_at: :desc))
|
||||
|
||||
# Apply filters
|
||||
@network_ranges = apply_filters(@network_ranges)
|
||||
|
||||
# Apply search
|
||||
if params[:search].present?
|
||||
@network_ranges = search_network_ranges(@network_ranges, params[:search])
|
||||
end
|
||||
|
||||
# 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
|
||||
@related_events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.id = ?", @network_range.id)
|
||||
.recent
|
||||
.limit(100)
|
||||
|
||||
@child_ranges = @network_range.child_ranges.limit(20)
|
||||
@parent_ranges = @network_range.parent_ranges.limit(10)
|
||||
@associated_rules = @network_range.rules.includes(:user).order(created_at: :desc)
|
||||
|
||||
# Traffic analytics (if we have events)
|
||||
@traffic_stats = calculate_traffic_stats(@network_range)
|
||||
end
|
||||
|
||||
# GET /network_ranges/new
|
||||
def new
|
||||
authorize NetworkRange
|
||||
@network_range = NetworkRange.new
|
||||
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
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
def set_project
|
||||
# For now, use the first project or create a default one
|
||||
@project = Project.first || Project.create!(
|
||||
name: 'Default Project',
|
||||
slug: 'default',
|
||||
public_key: SecureRandom.hex(32)
|
||||
)
|
||||
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)
|
||||
# Calculate traffic statistics for this network range
|
||||
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.id = ?", network_range.id)
|
||||
|
||||
{
|
||||
total_requests: events.count,
|
||||
unique_ips: events.distinct.count(:ip_address),
|
||||
blocked_requests: events.blocked.count,
|
||||
allowed_requests: events.allowed.count,
|
||||
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||
recent_activity: events.recent.limit(20)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,35 +1,24 @@
|
||||
class PasswordsController < ApplicationController
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_token, only: %i[ edit update ]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
if user = User.find_by(email_address: params[:email_address])
|
||||
PasswordsMailer.reset(user).deliver_later
|
||||
end
|
||||
|
||||
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||
end
|
||||
before_action :require_authentication
|
||||
|
||||
def edit
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update(params.permit(:password, :password_confirmation))
|
||||
@user.sessions.destroy_all
|
||||
redirect_to new_session_path, notice: "Password has been reset."
|
||||
@user = Current.user
|
||||
|
||||
if @user.authenticate(params[:current_password])
|
||||
if @user.update(params.permit(:password, :password_confirmation))
|
||||
@user.sessions.where.not(id: Current.session.id).destroy_all
|
||||
redirect_to root_path, notice: "Password updated successfully."
|
||||
else
|
||||
flash.now[:alert] = "New password confirmation didn't match."
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||
flash.now[:alert] = "Current password is incorrect."
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_user_by_token
|
||||
@user = User.find_by_password_reset_token!(params[:token])
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RuleSetsController < ApplicationController
|
||||
before_action :set_rule_set, only: [:show, :edit, :update, :push_to_agents]
|
||||
|
||||
def index
|
||||
@rule_sets = RuleSet.includes(:rules).by_priority
|
||||
end
|
||||
|
||||
def show
|
||||
@rules = @rule_set.rules.includes(:rule_set).by_priority
|
||||
end
|
||||
|
||||
def new
|
||||
@rule_set = RuleSet.new
|
||||
end
|
||||
|
||||
def create
|
||||
@rule_set = RuleSet.new(rule_set_params)
|
||||
|
||||
if @rule_set.save
|
||||
redirect_to @rule_set, notice: "Rule set was successfully created."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @rule_set.update(rule_set_params)
|
||||
redirect_to @rule_set, notice: "Rule set was successfully updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def push_to_agents
|
||||
@rule_set.push_to_agents!
|
||||
redirect_to @rule_set, notice: "Rule set pushed to agents successfully."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_rule_set
|
||||
@rule_set = RuleSet.find_by(slug: params[:id]) || RuleSet.find(params[:id])
|
||||
end
|
||||
|
||||
def rule_set_params
|
||||
params.require(:rule_set).permit(:name, :description, :enabled, :priority)
|
||||
end
|
||||
end
|
||||
@@ -1,29 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RulesController < ApplicationController
|
||||
# Follow proper before_action order:
|
||||
# 1. Authentication/Authorization
|
||||
allow_unauthenticated_access only: [:index, :show]
|
||||
|
||||
# 2. Resource loading
|
||||
before_action :set_rule, only: [:show, :edit, :update, :disable, :enable]
|
||||
before_action :authorize_rule
|
||||
before_action :set_project, only: [:index, :show]
|
||||
|
||||
# GET /rules
|
||||
def index
|
||||
@rules = Rule.includes(:project).order(created_at: :desc)
|
||||
@rules = policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc)
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
end
|
||||
|
||||
# GET /rules/new
|
||||
def new
|
||||
authorize Rule
|
||||
@rule = Rule.new
|
||||
|
||||
# Pre-fill from URL parameters
|
||||
if params[:network_range_id].present?
|
||||
network_range = NetworkRange.find_by(id: params[:network_range_id])
|
||||
@rule.network_range = network_range if network_range
|
||||
end
|
||||
|
||||
if params[:cidr].present?
|
||||
@rule.rule_type = 'network'
|
||||
end
|
||||
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
end
|
||||
|
||||
# POST /rules
|
||||
def create
|
||||
authorize Rule
|
||||
@rule = Rule.new(rule_params)
|
||||
@rule.user = Current.user
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
|
||||
# Handle network range creation if CIDR is provided
|
||||
if params[:cidr].present? && @rule.network_rule?
|
||||
network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range|
|
||||
range.user = Current.user
|
||||
range.source = 'manual'
|
||||
range.creation_reason = "Created for rule ##{@rule.id}"
|
||||
end
|
||||
@rule.network_range = network_range
|
||||
end
|
||||
|
||||
if @rule.save
|
||||
redirect_to @rule, notice: 'Rule was successfully created.'
|
||||
else
|
||||
@@ -33,16 +62,19 @@ class RulesController < ApplicationController
|
||||
|
||||
# GET /rules/:id
|
||||
def show
|
||||
authorize @rule
|
||||
end
|
||||
|
||||
# GET /rules/:id/edit
|
||||
def edit
|
||||
authorize @rule
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
end
|
||||
|
||||
# PATCH/PUT /rules/:id
|
||||
def update
|
||||
authorize @rule
|
||||
if @rule.update(rule_params)
|
||||
redirect_to @rule, notice: 'Rule was successfully updated.'
|
||||
else
|
||||
@@ -52,6 +84,7 @@ class RulesController < ApplicationController
|
||||
|
||||
# POST /rules/:id/disable
|
||||
def disable
|
||||
authorize @rule, :disable?
|
||||
reason = params[:reason] || "Disabled manually"
|
||||
@rule.disable!(reason: reason)
|
||||
redirect_to @rule, notice: 'Rule was successfully disabled.'
|
||||
@@ -59,6 +92,7 @@ class RulesController < ApplicationController
|
||||
|
||||
# POST /rules/:id/enable
|
||||
def enable
|
||||
authorize @rule, :enable?
|
||||
@rule.enable!
|
||||
redirect_to @rule, notice: 'Rule was successfully enabled.'
|
||||
end
|
||||
@@ -69,20 +103,32 @@ class RulesController < ApplicationController
|
||||
@rule = Rule.find(params[:id])
|
||||
end
|
||||
|
||||
def authorize_rule
|
||||
# Add authorization logic here if needed
|
||||
# For now, allow all authenticated users
|
||||
def rule_params
|
||||
permitted = [
|
||||
:rule_type,
|
||||
:action,
|
||||
:metadata,
|
||||
:expires_at,
|
||||
:enabled,
|
||||
:source,
|
||||
:network_range_id
|
||||
]
|
||||
|
||||
# Only include conditions for non-network rules
|
||||
if params[:rule][:rule_type] != 'network'
|
||||
permitted << :conditions
|
||||
end
|
||||
|
||||
def rule_params
|
||||
params.require(:rule).permit(
|
||||
:rule_type,
|
||||
:action,
|
||||
:conditions,
|
||||
:metadata,
|
||||
:expires_at,
|
||||
:enabled,
|
||||
:source
|
||||
params.require(:rule).permit(permitted)
|
||||
end
|
||||
|
||||
def set_project
|
||||
# For now, use the first project or create a default one
|
||||
@project = Project.first || Project.create!(
|
||||
name: 'Default Project',
|
||||
slug: 'default',
|
||||
public_key: SecureRandom.hex(32)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
Reference in New Issue
Block a user