path-matching #1
@@ -11,17 +11,41 @@ class AnalyticsController < ApplicationController
|
|||||||
@time_period = params[:period]&.to_sym || :day
|
@time_period = params[:period]&.to_sym || :day
|
||||||
@start_time = calculate_start_time(@time_period)
|
@start_time = calculate_start_time(@time_period)
|
||||||
|
|
||||||
# Core statistics
|
# Cache TTL based on time period
|
||||||
@total_events = Event.where("timestamp >= ?", @start_time).count
|
cache_ttl = case @time_period
|
||||||
@total_rules = Rule.enabled.count
|
when :hour then 5.minutes
|
||||||
@network_ranges_with_events = NetworkRange.with_events.count
|
when :day then 1.hour
|
||||||
@total_network_ranges = NetworkRange.count
|
when :week then 6.hours
|
||||||
|
when :month then 12.hours
|
||||||
|
else 1.hour
|
||||||
|
end
|
||||||
|
|
||||||
# Event breakdown by action
|
# Cache key includes period and start_time (hour-aligned for consistency)
|
||||||
@event_breakdown = Event.where("timestamp >= ?", @start_time)
|
cache_key_base = "analytics/#{@time_period}/#{@start_time.to_i}"
|
||||||
.group(:waf_action)
|
|
||||||
.count
|
# Core statistics - cached
|
||||||
.transform_keys do |action_id|
|
@total_events = Rails.cache.fetch("#{cache_key_base}/total_events", expires_in: cache_ttl) do
|
||||||
|
Event.where("timestamp >= ?", @start_time).count
|
||||||
|
end
|
||||||
|
|
||||||
|
@total_rules = Rails.cache.fetch("analytics/total_rules", expires_in: 5.minutes) do
|
||||||
|
Rule.enabled.count
|
||||||
|
end
|
||||||
|
|
||||||
|
@network_ranges_with_events = Rails.cache.fetch("analytics/network_ranges_with_events", expires_in: 5.minutes) do
|
||||||
|
NetworkRange.with_events.count
|
||||||
|
end
|
||||||
|
|
||||||
|
@total_network_ranges = Rails.cache.fetch("analytics/total_network_ranges", expires_in: 5.minutes) do
|
||||||
|
NetworkRange.count
|
||||||
|
end
|
||||||
|
|
||||||
|
# Event breakdown by action - cached
|
||||||
|
@event_breakdown = Rails.cache.fetch("#{cache_key_base}/event_breakdown", expires_in: cache_ttl) do
|
||||||
|
Event.where("timestamp >= ?", @start_time)
|
||||||
|
.group(:waf_action)
|
||||||
|
.count
|
||||||
|
.transform_keys do |action_id|
|
||||||
case action_id
|
case action_id
|
||||||
when 0 then 'allow'
|
when 0 then 'allow'
|
||||||
when 1 then 'deny'
|
when 1 then 'deny'
|
||||||
@@ -30,45 +54,64 @@ class AnalyticsController < ApplicationController
|
|||||||
else 'unknown'
|
else 'unknown'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Top countries by event count
|
# Top countries by event count - cached (this is the expensive JOIN query)
|
||||||
@top_countries = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
@top_countries = Rails.cache.fetch("#{cache_key_base}/top_countries", expires_in: cache_ttl) do
|
||||||
.where("timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time)
|
Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
.group("network_ranges.country")
|
.where("timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time)
|
||||||
.count
|
.group("network_ranges.country")
|
||||||
.sort_by { |_, count| -count }
|
.count
|
||||||
.first(10)
|
.sort_by { |_, count| -count }
|
||||||
|
.first(10)
|
||||||
|
end
|
||||||
|
|
||||||
# Top blocked IPs
|
# Top blocked IPs - cached
|
||||||
@top_blocked_ips = Event.where("timestamp >= ?", @start_time)
|
@top_blocked_ips = Rails.cache.fetch("#{cache_key_base}/top_blocked_ips", expires_in: cache_ttl) do
|
||||||
.where(waf_action: 1) # deny action in enum
|
Event.where("timestamp >= ?", @start_time)
|
||||||
.group(:ip_address)
|
.where(waf_action: 1) # deny action in enum
|
||||||
.count
|
.group(:ip_address)
|
||||||
.sort_by { |_, count| -count }
|
.count
|
||||||
.first(10)
|
.sort_by { |_, count| -count }
|
||||||
|
.first(10)
|
||||||
|
end
|
||||||
|
|
||||||
# Network range intelligence breakdown
|
# Network range intelligence breakdown - cached
|
||||||
@network_intelligence = {
|
@network_intelligence = Rails.cache.fetch("analytics/network_intelligence", expires_in: 10.minutes) do
|
||||||
datacenter_ranges: NetworkRange.datacenter.count,
|
{
|
||||||
vpn_ranges: NetworkRange.vpn.count,
|
datacenter_ranges: NetworkRange.datacenter.count,
|
||||||
proxy_ranges: NetworkRange.proxy.count,
|
vpn_ranges: NetworkRange.vpn.count,
|
||||||
total_ranges: NetworkRange.count
|
proxy_ranges: NetworkRange.proxy.count,
|
||||||
}
|
total_ranges: NetworkRange.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
# Recent activity
|
# Recent activity - minimal cache for freshness
|
||||||
@recent_events = Event.recent.limit(10)
|
@recent_events = Rails.cache.fetch("analytics/recent_events", expires_in: 1.minute) do
|
||||||
@recent_rules = Rule.order(created_at: :desc).limit(5)
|
Event.recent.limit(10).to_a
|
||||||
|
end
|
||||||
|
|
||||||
# System health indicators
|
@recent_rules = Rails.cache.fetch("analytics/recent_rules", expires_in: 5.minutes) do
|
||||||
@system_health = {
|
Rule.order(created_at: :desc).limit(5).to_a
|
||||||
total_users: User.count,
|
end
|
||||||
active_rules: Rule.enabled.count,
|
|
||||||
disabled_rules: Rule.where(enabled: false).count,
|
|
||||||
recent_errors: Event.where("timestamp >= ? AND waf_action = ?", @start_time, 1).count # 1 = deny
|
|
||||||
}
|
|
||||||
|
|
||||||
# Prepare data for charts
|
# System health indicators - cached
|
||||||
@chart_data = prepare_chart_data
|
@system_health = Rails.cache.fetch("#{cache_key_base}/system_health", expires_in: cache_ttl) do
|
||||||
|
{
|
||||||
|
total_users: User.count,
|
||||||
|
active_rules: Rule.enabled.count,
|
||||||
|
disabled_rules: Rule.where(enabled: false).count,
|
||||||
|
recent_errors: Event.where("timestamp >= ? AND waf_action = ?", @start_time, 1).count # 1 = deny
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Job queue statistics - short cache for near real-time
|
||||||
|
@job_statistics = Rails.cache.fetch("analytics/job_statistics", expires_in: 30.seconds) do
|
||||||
|
calculate_job_statistics
|
||||||
|
end
|
||||||
|
|
||||||
|
# Prepare data for charts - split caching for current vs historical data
|
||||||
|
@chart_data = prepare_chart_data_with_split_cache(cache_key_base, cache_ttl)
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html
|
format.html
|
||||||
@@ -130,30 +173,99 @@ class AnalyticsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def calculate_start_time(period)
|
def calculate_start_time(period)
|
||||||
|
# Snap to hour/day boundaries for cacheability
|
||||||
|
# Instead of rolling windows that change every second, use fixed boundaries
|
||||||
case period
|
case period
|
||||||
when :hour
|
when :hour
|
||||||
1.hour.ago
|
# Last complete hour: if it's 13:45, show 12:00-13:00
|
||||||
|
1.hour.ago.beginning_of_hour
|
||||||
when :day
|
when :day
|
||||||
24.hours.ago
|
# Last 24 complete hours from current hour boundary
|
||||||
|
24.hours.ago.beginning_of_hour
|
||||||
when :week
|
when :week
|
||||||
1.week.ago
|
# Last 7 complete days from today's start
|
||||||
|
7.days.ago.beginning_of_day
|
||||||
when :month
|
when :month
|
||||||
1.month.ago
|
# Last 30 complete days from today's start
|
||||||
|
30.days.ago.beginning_of_day
|
||||||
else
|
else
|
||||||
24.hours.ago
|
24.hours.ago.beginning_of_hour
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def prepare_chart_data_with_split_cache(cache_key_base, cache_ttl)
|
||||||
|
# Split timeline into historical (completed hours) and current (incomplete hour)
|
||||||
|
# Historical hours are cached for full TTL, current hour cached briefly for freshness
|
||||||
|
|
||||||
|
# Cache historical hours (1-23 hours ago) - these are complete and won't change
|
||||||
|
# No expiration - will stick around until evicted by cache store
|
||||||
|
historical_timeline = Rails.cache.fetch("#{cache_key_base}/chart_historical") do
|
||||||
|
historical_start = 23.hours.ago.beginning_of_hour
|
||||||
|
events_by_hour = Event.where("timestamp >= ? AND timestamp < ?", historical_start, Time.current.beginning_of_hour)
|
||||||
|
.group("DATE_TRUNC('hour', timestamp)")
|
||||||
|
.count
|
||||||
|
|
||||||
|
(1..23).map do |hour_ago|
|
||||||
|
hour_time = hour_ago.hours.ago.beginning_of_hour
|
||||||
|
hour_key = hour_time.utc
|
||||||
|
{
|
||||||
|
time_iso: hour_time.iso8601,
|
||||||
|
total: events_by_hour[hour_key] || 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Current hour (0 hours ago) - cache very briefly since it's actively accumulating
|
||||||
|
current_hour_data = Rails.cache.fetch("#{cache_key_base}/chart_current_hour", expires_in: 1.minute) do
|
||||||
|
hour_time = Time.current.beginning_of_hour
|
||||||
|
count = Event.where("timestamp >= ?", hour_time).count
|
||||||
|
{
|
||||||
|
time_iso: hour_time.iso8601,
|
||||||
|
total: count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Combine current + historical for full 24-hour timeline
|
||||||
|
timeline_data = [current_hour_data] + historical_timeline
|
||||||
|
|
||||||
|
# Action distribution and other chart data (cached with main cache)
|
||||||
|
other_chart_data = Rails.cache.fetch("#{cache_key_base}/chart_metadata", expires_in: cache_ttl) do
|
||||||
|
action_distribution = @event_breakdown.map do |action, count|
|
||||||
|
{
|
||||||
|
action: action.humanize,
|
||||||
|
count: count,
|
||||||
|
percentage: ((count.to_f / [@total_events, 1].max) * 100).round(1)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
actions: action_distribution,
|
||||||
|
countries: @top_countries.map { |country, count| { country: country, count: count } },
|
||||||
|
network_types: [
|
||||||
|
{ type: "Datacenter", count: @network_intelligence[:datacenter_ranges] },
|
||||||
|
{ type: "VPN", count: @network_intelligence[:vpn_ranges] },
|
||||||
|
{ type: "Proxy", count: @network_intelligence[:proxy_ranges] },
|
||||||
|
{ type: "Standard", count: @network_intelligence[:total_ranges] - @network_intelligence[:datacenter_ranges] - @network_intelligence[:vpn_ranges] - @network_intelligence[:proxy_ranges] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge timeline with other chart data
|
||||||
|
other_chart_data.merge(timeline: timeline_data)
|
||||||
|
end
|
||||||
|
|
||||||
def prepare_chart_data
|
def prepare_chart_data
|
||||||
# Events over time (hourly buckets for last 24 hours)
|
# Legacy method - kept for reference but no longer used
|
||||||
events_by_hour = Event.where("timestamp >= ?", 24.hours.ago)
|
# Events over time (hourly buckets) - use @start_time for consistency
|
||||||
|
events_by_hour = Event.where("timestamp >= ?", @start_time)
|
||||||
.group("DATE_TRUNC('hour', timestamp)")
|
.group("DATE_TRUNC('hour', timestamp)")
|
||||||
.count
|
.count
|
||||||
|
|
||||||
# Convert to chart format - keep everything in UTC for consistency
|
# Convert to chart format - snap to hour boundaries for cacheability
|
||||||
timeline_data = (0..23).map do |hour_ago|
|
timeline_data = (0..23).map do |hour_ago|
|
||||||
hour_time = hour_ago.hours.ago
|
# Use hour boundaries instead of rolling times
|
||||||
hour_key = hour_time.utc.beginning_of_hour
|
hour_time = hour_ago.hours.ago.beginning_of_hour
|
||||||
|
hour_key = hour_time.utc
|
||||||
|
|
||||||
{
|
{
|
||||||
# Store as ISO string for JavaScript to handle timezone conversion
|
# Store as ISO string for JavaScript to handle timezone conversion
|
||||||
@@ -311,4 +423,46 @@ class AnalyticsController < ApplicationController
|
|||||||
suspicious_patterns: @suspicious_patterns
|
suspicious_patterns: @suspicious_patterns
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def calculate_job_statistics
|
||||||
|
# Get job queue information from SolidQueue
|
||||||
|
begin
|
||||||
|
total_jobs = SolidQueue::Job.count
|
||||||
|
pending_jobs = SolidQueue::Job.where(finished_at: nil).count
|
||||||
|
recent_jobs = SolidQueue::Job.where('created_at > ?', 1.hour.ago).count
|
||||||
|
|
||||||
|
# Get jobs by queue name
|
||||||
|
queue_breakdown = SolidQueue::Job.group(:queue_name).count
|
||||||
|
|
||||||
|
# Get recent job activity
|
||||||
|
recent_enqueued = SolidQueue::Job.where('created_at > ?', 1.hour.ago).count
|
||||||
|
|
||||||
|
# Calculate health status
|
||||||
|
health_status = if pending_jobs > 100
|
||||||
|
'warning'
|
||||||
|
elsif pending_jobs > 500
|
||||||
|
'critical'
|
||||||
|
else
|
||||||
|
'healthy'
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
total_jobs: total_jobs,
|
||||||
|
pending_jobs: pending_jobs,
|
||||||
|
recent_enqueued: recent_enqueued,
|
||||||
|
queue_breakdown: queue_breakdown,
|
||||||
|
health_status: health_status
|
||||||
|
}
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to calculate job statistics: #{e.message}"
|
||||||
|
{
|
||||||
|
total_jobs: 0,
|
||||||
|
pending_jobs: 0,
|
||||||
|
recent_enqueued: 0,
|
||||||
|
queue_breakdown: {},
|
||||||
|
health_status: 'error',
|
||||||
|
error: e.message
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -2,19 +2,12 @@
|
|||||||
|
|
||||||
class DsnsController < ApplicationController
|
class DsnsController < ApplicationController
|
||||||
before_action :require_authentication
|
before_action :require_authentication
|
||||||
before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable]
|
before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable, :destroy]
|
||||||
before_action :authorize_dsn_management, except: [:index, :show]
|
before_action :authorize_dsn_management, except: [:index, :show]
|
||||||
|
|
||||||
# GET /dsns
|
# GET /dsns
|
||||||
def index
|
def index
|
||||||
@dsns = policy_scope(Dsn).order(created_at: :desc)
|
@dsns = policy_scope(Dsn).order(created_at: :desc)
|
||||||
|
|
||||||
# Generate environment DSNs using default DSN key or first enabled DSN
|
|
||||||
default_dsn = Dsn.enabled.first
|
|
||||||
if default_dsn
|
|
||||||
@external_dsn = generate_external_dsn(default_dsn.key)
|
|
||||||
@internal_dsn = generate_internal_dsn(default_dsn.key)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /dsns/new
|
# GET /dsns/new
|
||||||
@@ -64,6 +57,20 @@ class DsnsController < ApplicationController
|
|||||||
redirect_to @dsn, notice: 'DSN was enabled.'
|
redirect_to @dsn, notice: 'DSN was enabled.'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# DELETE /dsns/:id
|
||||||
|
def destroy
|
||||||
|
# Only allow deletion of disabled DSNs for safety
|
||||||
|
if @dsn.enabled?
|
||||||
|
redirect_to @dsn, alert: 'Cannot delete an enabled DSN. Please disable it first.'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
dsn_name = @dsn.name
|
||||||
|
@dsn.destroy
|
||||||
|
|
||||||
|
redirect_to dsns_path, notice: "DSN '#{dsn_name}' was successfully deleted."
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_dsn
|
def set_dsn
|
||||||
@@ -78,18 +85,4 @@ class DsnsController < ApplicationController
|
|||||||
# Only allow admins to manage DSNs
|
# Only allow admins to manage DSNs
|
||||||
redirect_to root_path, alert: 'Access denied' unless Current.user&.admin?
|
redirect_to root_path, alert: 'Access denied' unless Current.user&.admin?
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_external_dsn(key)
|
|
||||||
host = ENV.fetch("BAFFLE_HOST", "localhost:3000")
|
|
||||||
protocol = host.include?("localhost") ? "http" : "https"
|
|
||||||
"#{protocol}://#{key}@#{host}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_internal_dsn(key)
|
|
||||||
internal_host = ENV.fetch("BAFFLE_INTERNAL_HOST", nil)
|
|
||||||
return nil unless internal_host.present?
|
|
||||||
|
|
||||||
protocol = "http" # Internal connections use HTTP
|
|
||||||
"#{protocol}://#{key}@#{internal_host}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class EventsController < ApplicationController
|
class EventsController < ApplicationController
|
||||||
|
def show
|
||||||
|
@event = Event.find(params[:id])
|
||||||
|
@network_range = NetworkRange.contains_ip(@event.ip_address.to_s).first
|
||||||
|
|
||||||
|
# Auto-generate network range if no match found
|
||||||
|
unless @network_range
|
||||||
|
@network_range = NetworkRangeGenerator.find_or_create_for_ip(@event.ip_address)
|
||||||
|
Rails.logger.debug "Auto-generated network range #{@network_range&.cidr} for IP #{@event.ip_address}" if @network_range
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@events = Event.order(timestamp: :desc)
|
@events = Event.order(timestamp: :desc)
|
||||||
Rails.logger.debug "Found #{@events.count} total events"
|
Rails.logger.debug "Found #{@events.count} total events"
|
||||||
|
|||||||
31
app/controllers/settings_controller.rb
Normal file
31
app/controllers/settings_controller.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SettingsController < ApplicationController
|
||||||
|
before_action :require_authentication
|
||||||
|
before_action :authorize_settings_management
|
||||||
|
|
||||||
|
# GET /settings
|
||||||
|
def index
|
||||||
|
@settings = Setting.all.index_by(&:key)
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH /settings
|
||||||
|
def update
|
||||||
|
setting_key = params[:key]
|
||||||
|
setting_value = params[:value]
|
||||||
|
|
||||||
|
if setting_key.present?
|
||||||
|
Setting.set(setting_key, setting_value)
|
||||||
|
redirect_to settings_path, notice: 'Setting was successfully updated.'
|
||||||
|
else
|
||||||
|
redirect_to settings_path, alert: 'Invalid setting key.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def authorize_settings_management
|
||||||
|
# Only allow admins to manage settings
|
||||||
|
redirect_to root_path, alert: 'Access denied' unless Current.user&.admin?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -89,4 +89,54 @@ module ApplicationHelper
|
|||||||
|
|
||||||
raw html
|
raw html
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper methods for job queue status colors
|
||||||
|
def job_queue_status_color(status)
|
||||||
|
case status.to_s
|
||||||
|
when 'healthy'
|
||||||
|
'bg-green-500'
|
||||||
|
when 'warning'
|
||||||
|
'bg-yellow-500'
|
||||||
|
when 'critical'
|
||||||
|
'bg-red-500'
|
||||||
|
when 'error'
|
||||||
|
'bg-gray-500'
|
||||||
|
else
|
||||||
|
'bg-blue-500'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def job_queue_status_text_color(status)
|
||||||
|
case status.to_s
|
||||||
|
when 'healthy'
|
||||||
|
'text-green-600'
|
||||||
|
when 'warning'
|
||||||
|
'text-yellow-600'
|
||||||
|
when 'critical'
|
||||||
|
'text-red-600'
|
||||||
|
when 'error'
|
||||||
|
'text-gray-600'
|
||||||
|
else
|
||||||
|
'text-blue-600'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parse user agent string into readable components
|
||||||
|
def parse_user_agent(user_agent)
|
||||||
|
return nil if user_agent.blank?
|
||||||
|
|
||||||
|
client = DeviceDetector.new(user_agent)
|
||||||
|
|
||||||
|
{
|
||||||
|
name: client.name,
|
||||||
|
version: client.full_version,
|
||||||
|
os_name: client.os_name,
|
||||||
|
os_version: client.os_full_version,
|
||||||
|
device_type: client.device_type || "desktop",
|
||||||
|
device_name: client.device_name,
|
||||||
|
bot: client.bot?,
|
||||||
|
bot_name: client.bot_name,
|
||||||
|
raw: user_agent
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.startRefreshing()
|
// TEMPORARILY DISABLED: Auto-refresh causes performance issues with slow queries (30s+ load times)
|
||||||
|
// TODO: Re-enable after optimizing analytics queries
|
||||||
|
// this.startRefreshing()
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
|||||||
119
app/javascript/controllers/quick_create_rule_controller.js
Normal file
119
app/javascript/controllers/quick_create_rule_controller.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// QuickCreateRuleController - Handles the quick create rule form functionality
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["form", "toggle", "ruleTypeSelect", "actionSelect", "patternFields", "rateLimitFields", "redirectFields", "helpText", "conditionsField"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.setupEventListeners()
|
||||||
|
this.initializeFieldVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.formTarget.classList.toggle("hidden")
|
||||||
|
|
||||||
|
if (this.formTarget.classList.contains("hidden")) {
|
||||||
|
this.resetForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRuleTypeFields() {
|
||||||
|
if (!this.hasRuleTypeSelectTarget || !this.hasActionSelectTarget) return
|
||||||
|
|
||||||
|
const ruleType = this.ruleTypeSelectTarget.value
|
||||||
|
const action = this.actionSelectTarget.value
|
||||||
|
|
||||||
|
// Hide all optional fields
|
||||||
|
this.hideOptionalFields()
|
||||||
|
|
||||||
|
// Show relevant fields based on rule type
|
||||||
|
if (["path_pattern", "header_pattern", "query_pattern", "body_signature"].includes(ruleType)) {
|
||||||
|
if (this.hasPatternFieldsTarget) {
|
||||||
|
this.patternFieldsTarget.classList.remove("hidden")
|
||||||
|
this.updatePatternHelpText(ruleType)
|
||||||
|
}
|
||||||
|
} else if (ruleType === "rate_limit") {
|
||||||
|
if (this.hasRateLimitFieldsTarget) {
|
||||||
|
this.rateLimitFieldsTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show redirect fields if action is redirect
|
||||||
|
if (action === "redirect") {
|
||||||
|
if (this.hasRedirectFieldsTarget) {
|
||||||
|
this.redirectFieldsTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePatternHelpText(ruleType) {
|
||||||
|
if (!this.hasHelpTextTarget || !this.hasConditionsFieldTarget) return
|
||||||
|
|
||||||
|
const helpTexts = {
|
||||||
|
path_pattern: {
|
||||||
|
text: "Regex pattern to match URL paths (e.g.,\\.env$|wp-admin|phpmyadmin)",
|
||||||
|
placeholder: "Example: \\.env$|\\.git|config\\.php|wp-admin"
|
||||||
|
},
|
||||||
|
header_pattern: {
|
||||||
|
text: 'JSON with header_name and pattern (e.g., {"header_name": "User-Agent", "pattern": "bot.*"})',
|
||||||
|
placeholder: 'Example: {"header_name": "User-Agent", "pattern": ".*[Bb]ot.*"}'
|
||||||
|
},
|
||||||
|
query_pattern: {
|
||||||
|
text: "Regex pattern to match query parameters (e.g., union.*select|<script>)",
|
||||||
|
placeholder: "Example: (?:union|select|insert|update|delete).*\\s+(?:union|select)"
|
||||||
|
},
|
||||||
|
body_signature: {
|
||||||
|
text: "Regex pattern to match request body content (e.g., OR 1=1|<script>)",
|
||||||
|
placeholder: "Example: (?:OR\\s+1\\s*=\\s*1|AND\\s+1\\s*=\\s*1|UNION\\s+SELECT)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = helpTexts[ruleType]
|
||||||
|
if (config) {
|
||||||
|
this.helpTextTarget.textContent = config.text
|
||||||
|
this.conditionsFieldTarget.placeholder = config.placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideOptionalFields() {
|
||||||
|
if (this.hasPatternFieldsTarget) this.patternFieldsTarget.classList.add("hidden")
|
||||||
|
if (this.hasRateLimitFieldsTarget) this.rateLimitFieldsTarget.classList.add("hidden")
|
||||||
|
if (this.hasRedirectFieldsTarget) this.redirectFieldsTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
if (this.formTarget) {
|
||||||
|
this.formTarget.reset()
|
||||||
|
// Reset rule type to default
|
||||||
|
if (this.hasRuleTypeSelectTarget) {
|
||||||
|
this.ruleTypeSelectTarget.value = "network"
|
||||||
|
this.updateRuleTypeFields()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private methods
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Set up action change listener to show/hide redirect fields
|
||||||
|
if (this.hasActionSelectTarget) {
|
||||||
|
this.actionSelectTarget.addEventListener("change", () => {
|
||||||
|
this.updateRuleTypeFields()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up toggle button listener
|
||||||
|
if (this.hasToggleTarget) {
|
||||||
|
this.toggleTarget.addEventListener("click", () => {
|
||||||
|
this.toggle()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeFieldVisibility() {
|
||||||
|
// Initialize field visibility on page load
|
||||||
|
if (this.hasRuleTypeSelectTarget) {
|
||||||
|
this.updateRuleTypeFields()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/jobs/fetch_ipapi_data_job.rb
Normal file
38
app/jobs/fetch_ipapi_data_job.rb
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
class FetchIpapiDataJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# Fetches IPAPI enrichment data for a NetworkRange
|
||||||
|
# @param network_range_id [Integer] ID of the NetworkRange to enrich
|
||||||
|
def perform(network_range_id:)
|
||||||
|
network_range = NetworkRange.find_by(id: network_range_id)
|
||||||
|
return unless network_range
|
||||||
|
|
||||||
|
# Skip if we already have IPAPI data and it's recent (< 30 days old)
|
||||||
|
if network_range.has_network_data_from?(:ipapi) &&
|
||||||
|
network_range.last_api_fetch.present? &&
|
||||||
|
network_range.last_api_fetch > 30.days.ago
|
||||||
|
Rails.logger.info "Skipping IPAPI fetch for #{network_range.cidr} - data is recent"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Use the network address (first IP in range) as the representative IP
|
||||||
|
sample_ip = network_range.network_address.split('/').first
|
||||||
|
|
||||||
|
Rails.logger.info "Fetching IPAPI data for #{network_range.cidr} using IP #{sample_ip}"
|
||||||
|
|
||||||
|
ipapi_data = Ipapi.lookup(sample_ip)
|
||||||
|
|
||||||
|
if ipapi_data.present? && !ipapi_data.key?('error')
|
||||||
|
network_range.set_network_data(:ipapi, ipapi_data)
|
||||||
|
network_range.last_api_fetch = Time.current
|
||||||
|
network_range.save!
|
||||||
|
|
||||||
|
Rails.logger.info "Successfully fetched IPAPI data for #{network_range.cidr}"
|
||||||
|
else
|
||||||
|
Rails.logger.warn "IPAPI returned error for #{network_range.cidr}: #{ipapi_data}"
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to fetch IPAPI data for network_range #{network_range_id}: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class GenerateWafRulesJob < ApplicationJob
|
|
||||||
queue_as :waf_rules
|
|
||||||
|
|
||||||
def perform(event_id:)
|
|
||||||
event = Event.find(event_id)
|
|
||||||
|
|
||||||
# Only analyze blocked events for rule generation
|
|
||||||
return unless event.blocked?
|
|
||||||
|
|
||||||
# Generate different types of rules based on patterns
|
|
||||||
generate_ip_rules(event)
|
|
||||||
generate_path_rules(event)
|
|
||||||
generate_user_agent_rules(event)
|
|
||||||
generate_parameter_rules(event)
|
|
||||||
|
|
||||||
# Broadcast rule updates globally
|
|
||||||
ActionCable.server.broadcast("rules", { type: "refresh" })
|
|
||||||
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "Error generating WAF rules: #{e.message}"
|
|
||||||
Rails.logger.error e.backtrace.join("\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_ip_rules(event)
|
|
||||||
return unless event.ip_address.present?
|
|
||||||
|
|
||||||
# Check if this IP has multiple violations
|
|
||||||
violation_count = Event
|
|
||||||
.by_ip(event.ip_address)
|
|
||||||
.blocked
|
|
||||||
.where(timestamp: 24.hours.ago..Time.current)
|
|
||||||
.count
|
|
||||||
|
|
||||||
# Log high-violation IPs - no automatic blocking without projects
|
|
||||||
if violation_count >= 10
|
|
||||||
Rails.logger.info "IP with high violation count: #{event.ip_address} (#{violation_count} violations in 24 hours)"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_path_rules(event)
|
|
||||||
return unless event.request_path.present?
|
|
||||||
|
|
||||||
# Look for repeated attack patterns on specific paths
|
|
||||||
path_violations = project.events
|
|
||||||
.where(request_path: event.request_path)
|
|
||||||
.blocked
|
|
||||||
.where(timestamp: 1.hour.ago..Time.current)
|
|
||||||
.count
|
|
||||||
|
|
||||||
# Suggest path rules if 20+ violations on same path
|
|
||||||
if path_violations >= 20
|
|
||||||
suggest_path_rule(project, event.request_path, path_violations)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_user_agent_rules(event)
|
|
||||||
return unless event.user_agent.present?
|
|
||||||
|
|
||||||
# Look for malicious user agents
|
|
||||||
ua_violations = project.events
|
|
||||||
.by_user_agent(event.user_agent)
|
|
||||||
.blocked
|
|
||||||
.where(timestamp: 1.hour.ago..Time.current)
|
|
||||||
.count
|
|
||||||
|
|
||||||
# Suggest user agent rules if 15+ violations from same UA
|
|
||||||
if ua_violations >= 15
|
|
||||||
suggest_user_agent_rule(project, event.user_agent, ua_violations)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_parameter_rules(event)
|
|
||||||
params = event.query_params
|
|
||||||
return unless params.present?
|
|
||||||
|
|
||||||
# Look for suspicious parameter patterns
|
|
||||||
params.each do |key, value|
|
|
||||||
next unless value.is_a?(String)
|
|
||||||
|
|
||||||
# Check for common attack patterns in parameter values
|
|
||||||
if contains_attack_pattern?(value)
|
|
||||||
param_violations = project.events
|
|
||||||
.where("payload LIKE ?", "%#{key}%#{value}%")
|
|
||||||
.blocked
|
|
||||||
.where(timestamp: 6.hours.ago..Time.current)
|
|
||||||
.count
|
|
||||||
|
|
||||||
if param_violations >= 5
|
|
||||||
suggest_parameter_rule(project, key, value, param_violations)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def suggest_path_rule(project, path, violation_count)
|
|
||||||
# Create an issue for manual review
|
|
||||||
Issue.create!(
|
|
||||||
project: project,
|
|
||||||
title: "Suggested Path Rule",
|
|
||||||
description: "Path '#{path}' has #{violation_count} violations in 1 hour",
|
|
||||||
severity: "low",
|
|
||||||
metadata: {
|
|
||||||
type: "path_rule",
|
|
||||||
path: path,
|
|
||||||
violation_count: violation_count,
|
|
||||||
suggested_action: "block"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def suggest_user_agent_rule(project, user_agent, violation_count)
|
|
||||||
# Create an issue for manual review
|
|
||||||
Issue.create!(
|
|
||||||
project: project,
|
|
||||||
title: "Suggested User Agent Rule",
|
|
||||||
description: "User Agent '#{user_agent}' has #{violation_count} violations in 1 hour",
|
|
||||||
severity: "low",
|
|
||||||
metadata: {
|
|
||||||
type: "user_agent_rule",
|
|
||||||
user_agent: user_agent,
|
|
||||||
violation_count: violation_count,
|
|
||||||
suggested_action: "block"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def suggest_parameter_rule(project, param_name, param_value, violation_count)
|
|
||||||
# Create an issue for manual review
|
|
||||||
Issue.create!(
|
|
||||||
project: project,
|
|
||||||
title: "Suggested Parameter Rule",
|
|
||||||
description: "Parameter '#{param_name}' with suspicious values has #{violation_count} violations",
|
|
||||||
severity: "medium",
|
|
||||||
metadata: {
|
|
||||||
type: "parameter_rule",
|
|
||||||
param_name: param_name,
|
|
||||||
param_value: param_value,
|
|
||||||
violation_count: violation_count,
|
|
||||||
suggested_action: "block"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def contains_attack_pattern?(value)
|
|
||||||
# Common attack patterns
|
|
||||||
attack_patterns = [
|
|
||||||
/<script/i, # XSS
|
|
||||||
/union.*select/i, # SQL injection
|
|
||||||
/\.\./, # Directory traversal
|
|
||||||
/\/etc\/passwd/i, # File inclusion
|
|
||||||
/cmd\.exe/i, # Command injection
|
|
||||||
/eval\(/i, # Code injection
|
|
||||||
/javascript:/i, # JavaScript protocol
|
|
||||||
/onload=/i, # Event handler injection
|
|
||||||
]
|
|
||||||
|
|
||||||
attack_patterns.any? { |pattern| value.match?(pattern) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class ProcessWafAnalyticsJob < ApplicationJob
|
|
||||||
queue_as :waf_analytics
|
|
||||||
|
|
||||||
def perform(event_id:)
|
|
||||||
event = Event.find(event_id)
|
|
||||||
|
|
||||||
# Analyze event patterns
|
|
||||||
analyze_traffic_patterns(event)
|
|
||||||
analyze_geographic_distribution(event)
|
|
||||||
analyze_attack_vectors(event)
|
|
||||||
|
|
||||||
# Update global analytics cache
|
|
||||||
update_analytics_cache
|
|
||||||
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "Error processing WAF analytics: #{e.message}"
|
|
||||||
Rails.logger.error e.backtrace.join("\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def analyze_traffic_patterns(event)
|
|
||||||
# Look for unusual traffic spikes
|
|
||||||
recent_events = Event.where(timestamp: 5.minutes.ago..Time.current)
|
|
||||||
|
|
||||||
# Use a default threshold since we no longer have project-specific thresholds
|
|
||||||
threshold = 1000 # Default threshold
|
|
||||||
if recent_events.count > threshold * 5
|
|
||||||
# High traffic detected - create an issue
|
|
||||||
Issue.create!(
|
|
||||||
title: "High Traffic Spike Detected",
|
|
||||||
description: "Detected #{recent_events.count} requests in the last 5 minutes",
|
|
||||||
severity: "medium",
|
|
||||||
event_id: event.id,
|
|
||||||
metadata: {
|
|
||||||
event_count: recent_events.count,
|
|
||||||
time_window: "5 minutes",
|
|
||||||
threshold: threshold * 5
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze_geographic_distribution(event)
|
|
||||||
return unless event.has_geo_data?
|
|
||||||
|
|
||||||
country_code = event.lookup_country
|
|
||||||
return unless country_code.present?
|
|
||||||
|
|
||||||
# Check if this country is unusual globally by joining through network ranges
|
|
||||||
country_events = Event
|
|
||||||
.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
|
||||||
.where("network_ranges.country = ?", country_code)
|
|
||||||
.where(timestamp: 1.hour.ago..Time.current)
|
|
||||||
|
|
||||||
# If this is the first event from this country or unusual spike
|
|
||||||
if country_events.count == 1 || country_events.count > 100
|
|
||||||
Rails.logger.info "Unusual geographic activity from #{country_code}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze_attack_vectors(event)
|
|
||||||
return unless event.blocked?
|
|
||||||
|
|
||||||
# Analyze common attack patterns
|
|
||||||
analyze_ip_reputation(event)
|
|
||||||
analyze_user_agent_patterns(event)
|
|
||||||
analyze_path_attacks(event)
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze_ip_reputation(event)
|
|
||||||
return unless event.ip_address.present?
|
|
||||||
|
|
||||||
# Count recent blocks from this IP
|
|
||||||
recent_blocks = Event
|
|
||||||
.by_ip(event.ip_address)
|
|
||||||
.blocked
|
|
||||||
.where(timestamp: 1.hour.ago..Time.current)
|
|
||||||
|
|
||||||
if recent_blocks.count >= 5
|
|
||||||
# Log IP reputation issue - no automatic IP blocking without projects
|
|
||||||
Rails.logger.warn "IP with poor reputation detected: #{event.ip_address} (#{recent_blocks.count} blocks in 1 hour)"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze_user_agent_patterns(event)
|
|
||||||
return unless event.user_agent.present?
|
|
||||||
|
|
||||||
# Look for common bot/user agent patterns
|
|
||||||
suspicious_patterns = [
|
|
||||||
/bot/i, /crawler/i, /spider/i, /scanner/i,
|
|
||||||
/python/i, /curl/i, /wget/i, /nmap/i
|
|
||||||
]
|
|
||||||
|
|
||||||
if suspicious_patterns.any? { |pattern| event.user_agent.match?(pattern) }
|
|
||||||
# Log suspicious user agent for potential rule generation
|
|
||||||
Rails.logger.info "Suspicious user agent detected: #{event.user_agent}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze_path_attacks(event)
|
|
||||||
return unless event.request_path.present?
|
|
||||||
|
|
||||||
# Look for common attack paths
|
|
||||||
attack_patterns = [
|
|
||||||
/\.\./, # Directory traversal
|
|
||||||
/admin/i, # Admin panel access
|
|
||||||
/wp-admin/i, # WordPress admin
|
|
||||||
/\.php/i, # PHP files
|
|
||||||
/union.*select/i, # SQL injection
|
|
||||||
/script.*>/i # XSS attempts
|
|
||||||
]
|
|
||||||
|
|
||||||
if attack_patterns.any? { |pattern| event.request_path.match?(pattern) }
|
|
||||||
Rails.logger.info "Potential attack path detected: #{event.request_path}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_analytics_cache
|
|
||||||
# Update cached analytics for faster dashboard loading
|
|
||||||
Rails.cache.delete("global_analytics")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -20,47 +20,53 @@ class ProcessWafEventJob < ApplicationJob
|
|||||||
|
|
||||||
events_to_process.each do |single_event_data|
|
events_to_process.each do |single_event_data|
|
||||||
begin
|
begin
|
||||||
|
event_start = Time.current
|
||||||
|
|
||||||
# Generate unique event ID if not provided
|
# Generate unique event ID if not provided
|
||||||
event_id = single_event_data['event_id'] || SecureRandom.uuid
|
event_id = single_event_data['event_id'] || SecureRandom.uuid
|
||||||
|
|
||||||
# Create the WAF event record
|
# Create the WAF event record
|
||||||
|
create_start = Time.current
|
||||||
event = Event.create_from_waf_payload!(event_id, single_event_data)
|
event = Event.create_from_waf_payload!(event_id, single_event_data)
|
||||||
|
Rails.logger.debug "Event creation took #{((Time.current - create_start) * 1000).round(2)}ms"
|
||||||
|
|
||||||
# Log geo-location data status (uses NetworkRange delegation)
|
# Ensure network range exists for this IP and evaluate policies if needed
|
||||||
if event.ip_address.present?
|
|
||||||
begin
|
|
||||||
unless event.has_geo_data?
|
|
||||||
Rails.logger.debug "No geo data available for event #{event.id} with IP #{event.ip_address}"
|
|
||||||
end
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.warn "Failed to check geo data for event #{event.id}: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Ensure network range exists for this IP and process policies
|
|
||||||
if event.ip_address.present?
|
if event.ip_address.present?
|
||||||
begin
|
begin
|
||||||
|
network_start = Time.current
|
||||||
|
# Single lookup instead of checking has_geo_data? then querying again
|
||||||
existing_range = NetworkRange.contains_ip(event.ip_address.to_s).first
|
existing_range = NetworkRange.contains_ip(event.ip_address.to_s).first
|
||||||
network_range = existing_range || NetworkRangeGenerator.find_or_create_for_ip(event.ip_address)
|
network_range = existing_range || NetworkRangeGenerator.find_or_create_for_ip(event.ip_address)
|
||||||
|
Rails.logger.debug "Network range lookup/creation took #{((Time.current - network_start) * 1000).round(2)}ms"
|
||||||
|
|
||||||
if network_range
|
if network_range
|
||||||
Rails.logger.debug "Network range #{network_range.cidr} for event IP #{event.ip_address}"
|
Rails.logger.debug "Network range #{network_range.cidr} for event IP #{event.ip_address}"
|
||||||
|
|
||||||
# Process WAF policies for this network range
|
# Queue IPAPI enrichment if we don't have it yet
|
||||||
ProcessWafPoliciesJob.perform_later(network_range_id: network_range.id, event_id: event.id)
|
unless network_range.has_network_data_from?(:ipapi)
|
||||||
|
Rails.logger.info "Queueing IPAPI fetch for #{network_range.cidr}"
|
||||||
|
FetchIpapiDataJob.perform_later(network_range_id: network_range.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Evaluate WAF policies inline if needed (lazy evaluation)
|
||||||
|
# Only runs when: network never evaluated OR policies changed since last evaluation
|
||||||
|
if network_range.needs_policy_evaluation?
|
||||||
|
policy_start = Time.current
|
||||||
|
result = WafPolicyMatcher.evaluate_and_mark!(network_range)
|
||||||
|
Rails.logger.debug "Policy evaluation took #{((Time.current - policy_start) * 1000).round(2)}ms"
|
||||||
|
|
||||||
|
if result[:generated_rules].any?
|
||||||
|
Rails.logger.info "Generated #{result[:generated_rules].length} rules for #{network_range.cidr}"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.warn "Failed to create network range for event #{event.id}: #{e.message}"
|
Rails.logger.warn "Failed to process network range for event #{event.id}: #{e.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Trigger analytics processing
|
total_time = ((Time.current - event_start) * 1000).round(2)
|
||||||
ProcessWafAnalyticsJob.perform_later(event_id: event.id)
|
Rails.logger.info "Processed WAF event #{event_id} in #{total_time}ms"
|
||||||
|
|
||||||
# Check for automatic rule generation opportunities
|
|
||||||
GenerateWafRulesJob.perform_later(event_id: event.id)
|
|
||||||
|
|
||||||
Rails.logger.info "Processed WAF event #{event_id}"
|
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
Rails.logger.error "Failed to create WAF event: #{e.message}"
|
Rails.logger.error "Failed to create WAF event: #{e.message}"
|
||||||
Rails.logger.error e.record.errors.full_messages.join(", ")
|
Rails.logger.error e.record.errors.full_messages.join(", ")
|
||||||
@@ -70,9 +76,6 @@ class ProcessWafEventJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Broadcast real-time updates once per batch
|
|
||||||
ActionCable.server.broadcast("events", { type: "refresh" })
|
|
||||||
|
|
||||||
Rails.logger.info "Processed #{events_to_process.count} WAF events"
|
Rails.logger.info "Processed #{events_to_process.count} WAF events"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -17,8 +17,13 @@ class ProcessWafPoliciesJob < ApplicationJob
|
|||||||
Rails.logger.debug "Processing WAF policies for network range #{network_range.cidr}"
|
Rails.logger.debug "Processing WAF policies for network range #{network_range.cidr}"
|
||||||
|
|
||||||
# Use WafPolicyMatcher to find and generate rules
|
# Use WafPolicyMatcher to find and generate rules
|
||||||
matcher = WafPolicyMatcher.new(network_range: network_range)
|
begin
|
||||||
result = matcher.match_and_generate_rules
|
matcher = WafPolicyMatcher.new(network_range: network_range)
|
||||||
|
result = matcher.match_and_generate_rules
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "WafPolicyMatcher failed for network range #{network_range.cidr}: #{e.message}"
|
||||||
|
result = { matching_policies: [], generated_rules: [] }
|
||||||
|
end
|
||||||
|
|
||||||
# Log results
|
# Log results
|
||||||
if result[:matching_policies].any?
|
if result[:matching_policies].any?
|
||||||
@@ -42,27 +47,36 @@ class ProcessWafPoliciesJob < ApplicationJob
|
|||||||
Rails.logger.info " Challenge type: #{rule.challenge_type}"
|
Rails.logger.info " Challenge type: #{rule.challenge_type}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Trigger agent sync for new rules if there are any
|
|
||||||
if result[:generated_rules].any?
|
|
||||||
RulesSyncJob.perform_later
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
Rails.logger.debug "No matching policies found for network range #{network_range.cidr}"
|
Rails.logger.debug "No matching policies found for network range #{network_range.cidr}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Mark network range as evaluated
|
||||||
|
network_range.update_column(:policies_evaluated_at, Time.current)
|
||||||
|
|
||||||
# Update event record if provided
|
# Update event record if provided
|
||||||
if event_id.present?
|
if event_id.present?
|
||||||
event = Event.find_by(id: event_id)
|
event = Event.find_by(id: event_id)
|
||||||
if event.present?
|
if event.present?
|
||||||
# Add policy match information to event metadata
|
# Add policy match information to event metadata
|
||||||
event.update!(payload: event.payload.merge({
|
# Handle potential nil payload or type issues
|
||||||
|
current_payload = event.payload || {}
|
||||||
|
|
||||||
|
# Ensure payload is a hash before merging
|
||||||
|
unless current_payload.is_a?(Hash)
|
||||||
|
Rails.logger.warn "Event #{event_id} has invalid payload type: #{current_payload.class}, resetting to hash"
|
||||||
|
current_payload = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
event.update!(payload: current_payload.merge({
|
||||||
policy_matches: {
|
policy_matches: {
|
||||||
matching_policies_count: result[:matching_policies].length,
|
matching_policies_count: result[:matching_policies].length,
|
||||||
generated_rules_count: result[:generated_rules].length,
|
generated_rules_count: result[:generated_rules].length,
|
||||||
processed_at: Time.current.iso8601
|
processed_at: Time.current.iso8601
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Event #{event_id} not found for ProcessWafPoliciesJob, skipping update"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ class Dsn < ApplicationRecord
|
|||||||
|
|
||||||
def full_dsn_url
|
def full_dsn_url
|
||||||
# Generate a complete DSN URL like Sentry does
|
# Generate a complete DSN URL like Sentry does
|
||||||
# Format: https://{key}@{domain}/api/events
|
# Format: https://{key}@{domain}
|
||||||
domain = ENV['BAFFLE_HOST'] ||
|
domain = ENV['BAFFLE_HOST'] ||
|
||||||
Rails.application.config.action_mailer.default_url_options[:host] ||
|
Rails.application.config.action_mailer.default_url_options[:host] ||
|
||||||
ENV['RAILS_HOST'] ||
|
ENV['RAILS_HOST'] ||
|
||||||
'localhost:3000'
|
'localhost:3000'
|
||||||
|
|
||||||
protocol = Rails.env.development? ? 'http' : 'https'
|
protocol = Rails.env.development? ? 'http' : 'https'
|
||||||
"#{protocol}://#{key}@#{domain}/api/events"
|
"#{protocol}://#{key}@#{domain}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def api_endpoint_url
|
def api_endpoint_url
|
||||||
@@ -30,7 +30,7 @@ class Dsn < ApplicationRecord
|
|||||||
'localhost:3000'
|
'localhost:3000'
|
||||||
|
|
||||||
protocol = Rails.env.development? ? 'http' : 'https'
|
protocol = Rails.env.development? ? 'http' : 'https'
|
||||||
"#{protocol}://#{domain}/api/events"
|
"#{protocol}://#{domain}"
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -133,25 +133,11 @@ class NetworkRange < ApplicationRecord
|
|||||||
|
|
||||||
# Find nearest parent with intelligence data
|
# Find nearest parent with intelligence data
|
||||||
def parent_with_intelligence
|
def parent_with_intelligence
|
||||||
# Use Postgres network operators to find parent ranges directly
|
# Find all parent ranges (networks that contain this network)
|
||||||
cidr_str = network.to_s
|
# and look for any with intelligence data, ordered by specificity
|
||||||
if cidr_str.include?('/')
|
NetworkRange.where("?::inet <<= network", network.to_s)
|
||||||
addr_parts = network_address.split('.')
|
.where("masklen(network) < ?", prefix_length)
|
||||||
case addr_parts.length
|
.where("(asn IS NOT NULL OR company IS NOT NULL OR country IS NOT NULL OR is_datacenter = true OR is_vpn = true OR is_proxy = true)")
|
||||||
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")
|
.order("masklen(network) DESC")
|
||||||
.first
|
.first
|
||||||
end
|
end
|
||||||
@@ -238,6 +224,28 @@ class NetworkRange < ApplicationRecord
|
|||||||
self.additional_data = hash.to_json
|
self.additional_data = hash.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Network data accessors for different data sources
|
||||||
|
# network_data is a JSONB column with namespaced data:
|
||||||
|
# {
|
||||||
|
# geolite: {...}, # MaxMind GeoLite2 data
|
||||||
|
# ipapi: {...}, # IPAPI.is enrichment data
|
||||||
|
# abuseipdb: {...}, # Future: AbuseIPDB data
|
||||||
|
# shodan: {...} # Future: Shodan data
|
||||||
|
# }
|
||||||
|
def network_data_for(source)
|
||||||
|
network_data&.dig(source.to_s) || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_network_data(source, data)
|
||||||
|
self.network_data ||= {}
|
||||||
|
self.network_data[source.to_s] = data
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if we have network data from a specific source
|
||||||
|
def has_network_data_from?(source)
|
||||||
|
network_data&.key?(source.to_s) && network_data[source.to_s].present?
|
||||||
|
end
|
||||||
|
|
||||||
# String representations
|
# String representations
|
||||||
def to_s
|
def to_s
|
||||||
cidr
|
cidr
|
||||||
@@ -267,6 +275,19 @@ class NetworkRange < ApplicationRecord
|
|||||||
rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if this network range needs WAF policy evaluation
|
||||||
|
# Returns true if:
|
||||||
|
# - Never been evaluated, OR
|
||||||
|
# - Any WafPolicy has been updated since last evaluation
|
||||||
|
def needs_policy_evaluation?
|
||||||
|
return true if policies_evaluated_at.nil?
|
||||||
|
|
||||||
|
latest_policy_update = WafPolicy.maximum(:updated_at)
|
||||||
|
return false if latest_policy_update.nil? # No policies exist
|
||||||
|
|
||||||
|
policies_evaluated_at < latest_policy_update
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_default_source
|
def set_default_source
|
||||||
|
|||||||
18
app/models/setting.rb
Normal file
18
app/models/setting.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
class Setting < ApplicationRecord
|
||||||
|
validates :key, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
# Get a setting value by key, with optional fallback
|
||||||
|
def self.get(key, default = nil)
|
||||||
|
find_by(key: key)&.value || default
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set a setting value by key
|
||||||
|
def self.set(key, value)
|
||||||
|
find_or_initialize_by(key: key).update(value: value)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convenience method for ipapi.is API key
|
||||||
|
def self.ipapi_key
|
||||||
|
get('ipapi_key', ENV['IPAPI_KEY'])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -31,7 +31,8 @@ class EventNormalizer
|
|||||||
return unless hostname
|
return unless hostname
|
||||||
|
|
||||||
host = RequestHost.find_or_create_host(hostname)
|
host = RequestHost.find_or_create_host(hostname)
|
||||||
host.increment_usage! unless host.new_record?
|
# NOTE: usage_count increment removed for performance (was adding ~50ms per event)
|
||||||
|
# Can be recalculated with: RequestHost.all.each { |h| h.update(usage_count: h.events.count) }
|
||||||
@event.request_host = host
|
@event.request_host = host
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -83,7 +84,8 @@ class EventNormalizer
|
|||||||
|
|
||||||
segment_ids = segments.map do |segment|
|
segment_ids = segments.map do |segment|
|
||||||
path_segment = PathSegment.find_or_create_segment(segment)
|
path_segment = PathSegment.find_or_create_segment(segment)
|
||||||
path_segment.increment_usage! unless path_segment.new_record?
|
# NOTE: usage_count increment removed for performance (was adding ~100ms per event for paths with many segments)
|
||||||
|
# Can be recalculated with: PathSegment.all.each { |ps| ps.update(usage_count: Event.where("request_segment_ids @> ARRAY[?]", ps.id).count) }
|
||||||
path_segment.id
|
path_segment.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -138,12 +138,21 @@ class GeoliteAsnImporter
|
|||||||
# Validate network format
|
# Validate network format
|
||||||
IPAddr.new(network) # This will raise if invalid
|
IPAddr.new(network) # This will raise if invalid
|
||||||
|
|
||||||
|
# Store raw GeoLite ASN data in network_data
|
||||||
|
geolite_data = {
|
||||||
|
asn: {
|
||||||
|
autonomous_system_number: asn,
|
||||||
|
autonomous_system_organization: asn_org
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
NetworkRange.upsert(
|
NetworkRange.upsert(
|
||||||
{
|
{
|
||||||
network: network,
|
network: network,
|
||||||
asn: asn,
|
asn: asn,
|
||||||
asn_org: asn_org,
|
asn_org: asn_org,
|
||||||
source: 'geolite_asn',
|
source: 'geolite_asn',
|
||||||
|
network_data: { geolite: geolite_data },
|
||||||
updated_at: Time.current
|
updated_at: Time.current
|
||||||
},
|
},
|
||||||
unique_by: :index_network_ranges_on_network_unique
|
unique_by: :index_network_ranges_on_network_unique
|
||||||
|
|||||||
@@ -210,16 +210,21 @@ class GeoliteCountryImporter
|
|||||||
# Get location data - prefer geoname_id, then registered_country_geoname_id
|
# Get location data - prefer geoname_id, then registered_country_geoname_id
|
||||||
location_data = @locations_cache[geoname_id] || @locations_cache[registered_country_geoname_id] || {}
|
location_data = @locations_cache[geoname_id] || @locations_cache[registered_country_geoname_id] || {}
|
||||||
|
|
||||||
additional_data = {
|
# Store raw GeoLite country data in network_data[:geolite]
|
||||||
geoname_id: geoname_id,
|
geolite_data = {
|
||||||
registered_country_geoname_id: registered_country_geoname_id,
|
country: {
|
||||||
represented_country_geoname_id: row[:represented_country_geoname_id],
|
geoname_id: geoname_id,
|
||||||
continent_code: location_data[:continent_code],
|
registered_country_geoname_id: registered_country_geoname_id,
|
||||||
continent_name: location_data[:continent_name],
|
represented_country_geoname_id: row[:represented_country_geoname_id],
|
||||||
country_name: location_data[:country_name],
|
continent_code: location_data[:continent_code],
|
||||||
is_in_european_union: location_data[:is_in_european_union],
|
continent_name: location_data[:continent_name],
|
||||||
is_satellite_provider: is_satellite_provider,
|
country_name: location_data[:country_name],
|
||||||
is_anycast: is_anycast
|
country_iso_code: location_data[:country_iso_code],
|
||||||
|
is_in_european_union: location_data[:is_in_european_union],
|
||||||
|
is_anonymous_proxy: is_anonymous_proxy,
|
||||||
|
is_satellite_provider: is_satellite_provider,
|
||||||
|
is_anycast: is_anycast
|
||||||
|
}
|
||||||
}.compact
|
}.compact
|
||||||
|
|
||||||
NetworkRange.upsert(
|
NetworkRange.upsert(
|
||||||
@@ -228,7 +233,7 @@ class GeoliteCountryImporter
|
|||||||
country: location_data[:country_iso_code],
|
country: location_data[:country_iso_code],
|
||||||
is_proxy: is_anonymous_proxy,
|
is_proxy: is_anonymous_proxy,
|
||||||
source: 'geolite_country',
|
source: 'geolite_country',
|
||||||
additional_data: additional_data,
|
network_data: { geolite: geolite_data },
|
||||||
updated_at: Time.current
|
updated_at: Time.current
|
||||||
},
|
},
|
||||||
unique_by: :index_network_ranges_on_network_unique
|
unique_by: :index_network_ranges_on_network_unique
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
class Ipapi
|
class Ipapi
|
||||||
include HTTParty
|
include HTTParty
|
||||||
BASE_URL = "https://api.ipapi.is/"
|
BASE_URL = "https://api.ipapi.is/"
|
||||||
API_KEY = Rails.application.credentials.ipapi_key
|
|
||||||
|
def api_key = Setting.ipapi_key
|
||||||
|
|
||||||
def lookup(ip)
|
def lookup(ip)
|
||||||
response = self.class.get("#{BASE_URL}", query: { q: ip, key: API_KEY })
|
return unless api_key.present?
|
||||||
response.parsed_response
|
response = self.class.get("#{BASE_URL}", query: { q: ip, key: api_key })
|
||||||
end
|
response.parsed_response
|
||||||
|
end
|
||||||
|
|
||||||
def self.lookup(ip) = new.lookup(ip)
|
def self.lookup(ip) = new.lookup(ip)
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ end
|
|||||||
if ip.is_a?(Array)
|
if ip.is_a?(Array)
|
||||||
post_data(ip)
|
post_data(ip)
|
||||||
else
|
else
|
||||||
response = self.class.get("#{BASE_URL}", query: { q: ip, key: API_KEY })
|
response = self.class.get("#{BASE_URL}", query: { q: ip, key: api_key })
|
||||||
response.parsed_response
|
response.parsed_response
|
||||||
end
|
end
|
||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
@@ -28,7 +30,7 @@ end
|
|||||||
|
|
||||||
def post_data(ips)
|
def post_data(ips)
|
||||||
response = self.class.post("#{BASE_URL}",
|
response = self.class.post("#{BASE_URL}",
|
||||||
query: { key: API_KEY },
|
query: { key: api_key },
|
||||||
body: { ips: ips }.to_json,
|
body: { ips: ips }.to_json,
|
||||||
headers: { 'Content-Type' => 'application/json' }
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
)
|
)
|
||||||
@@ -39,7 +41,13 @@ end
|
|||||||
IPAddr.new(ip)
|
IPAddr.new(ip)
|
||||||
cidr = data.dig("asn", "route")
|
cidr = data.dig("asn", "route")
|
||||||
|
|
||||||
NetworkRange.add_network(cidr).tap { |acl| acl&.update(ip_api_data: data) }
|
network_range = NetworkRange.add_network(cidr)
|
||||||
|
if network_range
|
||||||
|
network_range.set_network_data(:ipapi, data)
|
||||||
|
network_range.last_api_fetch = Time.current
|
||||||
|
network_range.save
|
||||||
|
end
|
||||||
|
network_range
|
||||||
rescue IPAddr::InvalidAddressError
|
rescue IPAddr::InvalidAddressError
|
||||||
puts "Skipping #{ip}"
|
puts "Skipping #{ip}"
|
||||||
next
|
next
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ class WafPolicyMatcher
|
|||||||
def match_and_generate_rules
|
def match_and_generate_rules
|
||||||
find_matching_policies
|
find_matching_policies
|
||||||
generate_rules
|
generate_rules
|
||||||
|
|
||||||
|
# Return hash format expected by ProcessWafPoliciesJob
|
||||||
|
{
|
||||||
|
matching_policies: @matching_policies,
|
||||||
|
generated_rules: @generated_rules
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Class methods for batch processing
|
# Class methods for batch processing
|
||||||
@@ -81,6 +87,20 @@ class WafPolicyMatcher
|
|||||||
matcher.match_and_generate_rules
|
matcher.match_and_generate_rules
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Evaluate a network range against policies and mark it as evaluated
|
||||||
|
# This is the main entry point for inline policy evaluation
|
||||||
|
def self.evaluate_and_mark!(network_range)
|
||||||
|
return { matching_policies: [], generated_rules: [] } unless network_range
|
||||||
|
|
||||||
|
matcher = new(network_range: network_range)
|
||||||
|
result = matcher.match_and_generate_rules
|
||||||
|
|
||||||
|
# Mark this network range as evaluated
|
||||||
|
network_range.update_column(:policies_evaluated_at, Time.current)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
def self.batch_process_network_ranges(network_ranges)
|
def self.batch_process_network_ranges(network_ranges)
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
<!-- Key Statistics Cards -->
|
<!-- Key Statistics Cards -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||||
<!-- Total Events -->
|
<!-- Total Events -->
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
@@ -148,6 +148,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Queue -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 <%= job_queue_status_color(@job_statistics[:health_status]) %> rounded-md flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Job Queue</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@job_statistics[:pending_jobs]) %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="<%= job_queue_status_text_color(@job_statistics[:health_status]) %> font-medium">
|
||||||
|
<%= @job_statistics[:health_status].humanize %>
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500"> · <%= @job_statistics[:recent_enqueued] %> recent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts and Detailed Analytics -->
|
<!-- Charts and Detailed Analytics -->
|
||||||
@@ -214,7 +243,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secondary Information Rows -->
|
<!-- Secondary Information Rows -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
<!-- Top Countries -->
|
<!-- Top Countries -->
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
@@ -283,6 +312,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Queue Details -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Job Queue Details</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% if @job_statistics[:queue_breakdown].any? %>
|
||||||
|
<% @job_statistics[:queue_breakdown].each do |queue, count| %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-2 h-2 rounded-full mr-2
|
||||||
|
<%= case queue.to_s
|
||||||
|
when 'default' then 'bg-blue-500'
|
||||||
|
when 'waf_policies' then 'bg-purple-500'
|
||||||
|
when 'waf_events' then 'bg-green-500'
|
||||||
|
else 'bg-gray-500'
|
||||||
|
end %>"></div>
|
||||||
|
<span class="text-sm text-gray-900"><%= queue.to_s.humanize %></span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(count) %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-500 text-center py-4">No job queue data available</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top Blocked IPs -->
|
<!-- Top Blocked IPs -->
|
||||||
|
|||||||
@@ -215,9 +215,10 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<% unless data_import.processing? %>
|
<% unless data_import.processing? %>
|
||||||
<%= link_to "Delete", data_import, method: :delete,
|
<%= link_to "Delete", data_import,
|
||||||
data: {
|
data: {
|
||||||
confirm: "Are you sure you want to delete this import?"
|
turbo_method: :delete,
|
||||||
|
turbo_confirm: "Are you sure you want to delete this import?"
|
||||||
},
|
},
|
||||||
class: "text-red-600 hover:text-red-900" %>
|
class: "text-red-600 hover:text-red-900" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<%= link_to "← Back to Imports", data_imports_path, class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
<%= link_to "← Back to Imports", data_imports_path, class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
<% unless @data_import.processing? %>
|
<% unless @data_import.processing? %>
|
||||||
<%= link_to "Delete", @data_import, method: :delete,
|
<%= link_to "Delete", @data_import,
|
||||||
data: {
|
data: {
|
||||||
confirm: "Are you sure you want to delete this import record?"
|
turbo_method: :delete,
|
||||||
|
turbo_confirm: "Are you sure you want to delete this import record?"
|
||||||
},
|
},
|
||||||
class: "inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
|
class: "inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -16,58 +16,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Environment DSNs -->
|
|
||||||
<div class="bg-white shadow overflow-hidden sm:rounded-md mb-8">
|
|
||||||
<div class="px-4 py-5 sm:px-6">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Environment DSNs</h3>
|
|
||||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
|
||||||
Default DSNs configured via environment variables for agent connectivity.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-gray-200">
|
|
||||||
<dl>
|
|
||||||
<!-- BAFFLE_HOST DSN -->
|
|
||||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">External DSN (BAFFLE_HOST)</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-mono">
|
|
||||||
<%= @external_dsn %>
|
|
||||||
</code>
|
|
||||||
<button onclick="copyToClipboard('<%= @external_dsn %>')" class="text-blue-600 hover:text-blue-800 text-sm">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-400 mt-1">Host: <%= ENV['BAFFLE_HOST'] || 'localhost:3000' %></p>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @internal_dsn.present? %>
|
|
||||||
<!-- BAFFLE_INTERNAL_HOST DSN -->
|
|
||||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Internal DSN (BAFFLE_INTERNAL_HOST)</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-mono">
|
|
||||||
<%= @internal_dsn %>
|
|
||||||
</code>
|
|
||||||
<button onclick="copyToClipboard('<%= @internal_dsn %>')" class="text-blue-600 hover:text-blue-800 text-sm">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-400 mt-1">Host: <%= ENV['BAFFLE_INTERNAL_HOST'] %></p>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Database DSNs -->
|
<!-- Database DSNs -->
|
||||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
@@ -120,6 +68,13 @@
|
|||||||
<%= link_to "Enable", enable_dsn_path(dsn), method: :post,
|
<%= link_to "Enable", enable_dsn_path(dsn), method: :post,
|
||||||
class: "text-green-600 hover:text-green-900 text-sm font-medium" %>
|
class: "text-green-600 hover:text-green-900 text-sm font-medium" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if policy(dsn).destroy? && !dsn.enabled? %>
|
||||||
|
<%= button_to "Delete", dsn, method: :delete,
|
||||||
|
data: {
|
||||||
|
confirm: "Are you sure you want to delete '#{dsn.name}'? This action cannot be undone."
|
||||||
|
},
|
||||||
|
class: "text-red-700 hover:text-red-900 text-sm font-medium font-semibold bg-transparent border-none cursor-pointer p-0" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -17,6 +17,13 @@
|
|||||||
<% if policy(@dsn).edit? %>
|
<% if policy(@dsn).edit? %>
|
||||||
<%= link_to "Edit", edit_dsn_path(@dsn), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700" %>
|
<%= link_to "Edit", edit_dsn_path(@dsn), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if policy(@dsn).destroy? && !@dsn.enabled? %>
|
||||||
|
<%= button_to "Delete", @dsn, method: :delete,
|
||||||
|
data: {
|
||||||
|
confirm: "Are you sure you want to delete '#{@dsn.name}'? This action cannot be undone and the DSN key will be permanently removed."
|
||||||
|
},
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,7 +148,7 @@
|
|||||||
|
|
||||||
<h4 class="mt-4">Query Parameter Authentication</h4>
|
<h4 class="mt-4">Query Parameter Authentication</h4>
|
||||||
<p>Include the DSN key as a query parameter:</p>
|
<p>Include the DSN key as a query parameter:</p>
|
||||||
<pre class="bg-gray-100 p-3 rounded text-sm"><code>/api/events?baffle_key=<%= @dsn.key %></code></pre>
|
<pre class="bg-gray-100 p-3 rounded text-sm"><code>/api/v1/events?baffle_key=<%= @dsn.key %></code></pre>
|
||||||
|
|
||||||
<h4 class="mt-4">X-Baffle-Auth Header</h4>
|
<h4 class="mt-4">X-Baffle-Auth Header</h4>
|
||||||
<p>Use the custom Baffle authentication header:</p>
|
<p>Use the custom Baffle authentication header:</p>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<% @events.each do |event| %>
|
<% @events.each do |event| %>
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50 cursor-pointer" onclick="window.location='<%= event_path(event) %>'">
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
<div class="text-gray-900" data-timeline-target="timestamp" data-iso="<%= event.timestamp.iso8601 %>">
|
<div class="text-gray-900" data-timeline-target="timestamp" data-iso="<%= event.timestamp.iso8601 %>">
|
||||||
<%= event.timestamp.strftime("%H:%M:%S") %>
|
<%= event.timestamp.strftime("%H:%M:%S") %>
|
||||||
@@ -135,9 +135,8 @@
|
|||||||
<td class="px-6 py-4 text-sm text-gray-900">
|
<td class="px-6 py-4 text-sm text-gray-900">
|
||||||
<% network_range = @network_ranges_by_ip[event.ip_address.to_s] %>
|
<% network_range = @network_ranges_by_ip[event.ip_address.to_s] %>
|
||||||
<% if network_range %>
|
<% if network_range %>
|
||||||
<%= link_to event.ip_address, network_range_path(network_range),
|
<%= link_to event.ip_address, network_range_path(event.ip_address),
|
||||||
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||||
|
|
||||||
<!-- Network Intelligence Summary -->
|
<!-- Network Intelligence Summary -->
|
||||||
<div class="mt-1 space-y-1">
|
<div class="mt-1 space-y-1">
|
||||||
<% if network_range.company.present? %>
|
<% if network_range.company.present? %>
|
||||||
@@ -161,7 +160,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
<%= network_range.cidr %>
|
<%= link_to network_range.cidr, network_range_path(network_range) %>
|
||||||
<% if network_range.asn.present? %>
|
<% if network_range.asn.present? %>
|
||||||
• ASN <%= network_range.asn %>
|
• ASN <%= network_range.asn %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -184,8 +183,10 @@
|
|||||||
<%= event.waf_action %>
|
<%= event.waf_action %>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 max-w-xs truncate" title="<%= event.request_path %>">
|
<td class="px-6 py-4 text-sm font-mono text-gray-900">
|
||||||
<%= event.request_path || '-' %>
|
<div class="max-w-md break-all">
|
||||||
|
<%= event.request_path || '-' %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
<%= event.request_method ? event.request_method.upcase : '-' %>
|
<%= event.request_method ? event.request_method.upcase : '-' %>
|
||||||
@@ -202,8 +203,35 @@
|
|||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title="<%= event.user_agent %>">
|
<td class="px-6 py-4 text-sm text-gray-900">
|
||||||
<%= event.user_agent&.truncate(50) || '-' %>
|
<% if event.user_agent.present? %>
|
||||||
|
<% ua = parse_user_agent(event.user_agent) %>
|
||||||
|
<div class="space-y-0.5" title="<%= ua[:raw] %>">
|
||||||
|
<div class="font-medium text-gray-900">
|
||||||
|
<%= ua[:name] if ua[:name].present? %>
|
||||||
|
<% if ua[:version].present? && ua[:name].present? %>
|
||||||
|
<span class="text-gray-500 font-normal"><%= ua[:version] %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if ua[:os_name].present? %>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<%= ua[:os_name] %>
|
||||||
|
<% if ua[:os_version].present? %>
|
||||||
|
<%= ua[:os_version] %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if ua[:bot] %>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">
|
||||||
|
🤖 <%= ua[:bot_name] || 'Bot' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
337
app/views/events/show.html.erb
Normal file
337
app/views/events/show.html.erb
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<% content_for :title, "Event #{@event.event_id} - Baffle Hub" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8" data-controller="timeline" data-timeline-mode-value="events">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<nav class="flex" aria-label="Breadcrumb">
|
||||||
|
<ol class="flex items-center space-x-4">
|
||||||
|
<li>
|
||||||
|
<%= link_to "Events", events_path, class: "text-gray-500 hover:text-gray-700" %>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="flex-shrink-0 h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span class="ml-4 text-gray-700 font-medium"><%= @event.event_id %></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-2 flex items-center space-x-3">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Event Details</h1>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
<%= case @event.waf_action
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'redirect' then 'bg-blue-100 text-blue-800'
|
||||||
|
when 'challenge' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
else 'bg-gray-100 text-gray-800'
|
||||||
|
end %>">
|
||||||
|
<%= @event.waf_action.upcase %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "Back to Events", events_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Overview -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Event Overview</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Event ID</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono break-all"><%= @event.event_id %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Timestamp</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<div data-timeline-target="timestamp" data-iso="<%= @event.timestamp.iso8601 %>">
|
||||||
|
<%= @event.timestamp.strftime("%Y-%m-%d %H:%M:%S %Z") %>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
|
<%= time_ago_in_words(@event.timestamp) %> ago
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Action</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
<%= case @event.waf_action
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'redirect' then 'bg-blue-100 text-blue-800'
|
||||||
|
when 'challenge' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
else 'bg-gray-100 text-gray-800'
|
||||||
|
end %>">
|
||||||
|
<%= @event.waf_action %>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% if @event.rule_matched.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Rule Matched</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @event.rule_matched %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @event.blocked_reason.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Blocked Reason</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @event.blocked_reason %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @event.response_status.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Response Status</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @event.response_status %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @event.response_time_ms.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Response Time</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @event.response_time_ms %> ms</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Details -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Request Details</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Request URL</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono break-all"><%= @event.request_url || @event.request_path %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Request Path</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono break-all"><%= @event.request_path %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Method</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono"><%= @event.request_method ? @event.request_method.upcase : '-' %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Protocol</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono"><%= @event.request_protocol || '-' %></dd>
|
||||||
|
</div>
|
||||||
|
<% if @event.request_host %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Host</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono"><%= @event.request_host.hostname %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Intelligence -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">IP Address</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @network_range %>
|
||||||
|
<%= link_to @event.ip_address, network_range_path(@event.ip_address),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||||
|
<% else %>
|
||||||
|
<span class="font-mono"><%= @event.ip_address %></span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% if @network_range %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Network Range</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= link_to @network_range.cidr, network_range_path(@network_range),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% if @network_range.company.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Company</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.company %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.asn.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">ASN</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= link_to "#{@network_range.asn} (#{@network_range.asn_org})", network_ranges_path(asn: @network_range.asn),
|
||||||
|
class: "text-blue-600 hover:text-blue-900 hover:underline" %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.country.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Country</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= link_to @network_range.country, events_path(country: @network_range.country),
|
||||||
|
class: "text-blue-600 hover:text-blue-900 hover:underline" %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.is_datacenter? || @network_range.is_vpn? || @network_range.is_proxy? %>
|
||||||
|
<div class="md:col-span-2 lg:col-span-3">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 mb-2">Classification</dt>
|
||||||
|
<dd class="flex flex-wrap gap-2">
|
||||||
|
<% if @network_range.is_datacenter? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">Datacenter</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.is_vpn? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">VPN</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.is_proxy? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">Proxy</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Agent -->
|
||||||
|
<% if @event.user_agent.present? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">User Agent</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<% ua = parse_user_agent(@event.user_agent) %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Browser</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if ua[:name].present? %>
|
||||||
|
<%= ua[:name] %>
|
||||||
|
<% if ua[:version].present? %>
|
||||||
|
<span class="text-gray-500"><%= ua[:version] %></span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Operating System</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if ua[:os_name].present? %>
|
||||||
|
<%= ua[:os_name] %>
|
||||||
|
<% if ua[:os_version].present? %>
|
||||||
|
<span class="text-gray-500"><%= ua[:os_version] %></span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Device Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= ua[:device_type]&.humanize || "-" %></dd>
|
||||||
|
</div>
|
||||||
|
<% if ua[:bot] %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Bot Detection</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
|
||||||
|
🤖 <%= ua[:bot_name] || 'Bot' %>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="md:col-span-2 lg:col-span-3">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Raw User Agent</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono break-all bg-gray-50 p-3 rounded"><%= @event.user_agent %></dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<% if @event.tags.any? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Tags</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% @event.tags.each do |tag| %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= tag %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Server Information -->
|
||||||
|
<% if @event.server_name.present? || @event.environment.present? || @event.agent_name.present? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Server & Agent Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<% if @event.server_name.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Server Name</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @event.server_name %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @event.environment.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Environment</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @event.environment %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @event.agent_name.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Agent</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= @event.agent_name %>
|
||||||
|
<% if @event.agent_version.present? %>
|
||||||
|
<span class="text-gray-500">v<%= @event.agent_version %></span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Raw Payload -->
|
||||||
|
<% if @event.payload.present? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Raw Event Payload</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<pre class="bg-gray-50 p-4 rounded-md text-xs overflow-x-auto"><%= JSON.pretty_generate(@event.payload) %></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -122,6 +122,10 @@
|
|||||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
||||||
👥 Manage Users
|
👥 Manage Users
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to settings_path,
|
||||||
|
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
||||||
|
🔧 Settings
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="border-t border-gray-100"></div>
|
<div class="border-t border-gray-100"></div>
|
||||||
|
|||||||
@@ -68,6 +68,21 @@
|
|||||||
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.ipv4? ? "IPv4" : "IPv6" %></dd>
|
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.ipv4? ? "IPv4" : "IPv6" %></dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Supernet display -->
|
||||||
|
<% parent_with_intelligence = @network_range.parent_with_intelligence %>
|
||||||
|
<% if parent_with_intelligence && parent_with_intelligence.cidr != @network_range.cidr %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Supernet</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= link_to parent_with_intelligence.cidr, network_range_path(parent_with_intelligence),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||||
|
<% if parent_with_intelligence.company.present? %>
|
||||||
|
<span class="ml-2 text-xs text-gray-500">(<%= parent_with_intelligence.company %>)</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% if @network_range.asn.present? %>
|
<% if @network_range.asn.present? %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">ASN</dt>
|
<dt class="text-sm font-medium text-gray-500">ASN</dt>
|
||||||
@@ -220,12 +235,12 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Associated Rules -->
|
<!-- Associated Rules -->
|
||||||
<div class="bg-white shadow rounded-lg mb-6">
|
<div class="bg-white shadow rounded-lg mb-6" data-controller="quick-create-rule">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Associated Rules (<%= @associated_rules.count %>)</h3>
|
<h3 class="text-lg font-medium text-gray-900">Associated Rules (<%= @associated_rules.count %>)</h3>
|
||||||
<% if @network_range.persisted? %>
|
<% if @network_range.persisted? %>
|
||||||
<button type="button" onclick="toggleQuickCreateRule()" class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
<button type="button" data-action="click->quick-create-rule#toggle" data-quick-create-rule-target="toggle" class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -239,7 +254,7 @@
|
|||||||
|
|
||||||
<!-- Quick Create Rule Form -->
|
<!-- Quick Create Rule Form -->
|
||||||
<% if @network_range.persisted? %>
|
<% if @network_range.persisted? %>
|
||||||
<div id="quick_create_rule" class="hidden border-b border-gray-200">
|
<div id="quick_create_rule" data-quick-create-rule-target="form" class="hidden border-b border-gray-200">
|
||||||
<div class="px-6 py-4 bg-blue-50">
|
<div class="px-6 py-4 bg-blue-50">
|
||||||
<%= form_with(model: Rule.new, url: rules_path, local: true,
|
<%= form_with(model: Rule.new, url: rules_path, local: true,
|
||||||
class: "space-y-4",
|
class: "space-y-4",
|
||||||
@@ -290,7 +305,7 @@
|
|||||||
{
|
{
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
id: "quick_rule_type_select",
|
id: "quick_rule_type_select",
|
||||||
onchange: "toggleRuleTypeFields()"
|
data: { quick_create_rule_target: "ruleTypeSelect", action: "change->quick-create-rule#updateRuleTypeFields" }
|
||||||
} %>
|
} %>
|
||||||
<p class="mt-1 text-xs text-gray-500">Select the type of rule to create</p>
|
<p class="mt-1 text-xs text-gray-500">Select the type of rule to create</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +323,10 @@
|
|||||||
['Monitor - Log but allow', 'monitor']
|
['Monitor - Log but allow', 'monitor']
|
||||||
], 'deny'),
|
], 'deny'),
|
||||||
{ },
|
{ },
|
||||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
{
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
data: { quick_create_rule_target: "actionSelect", action: "change->quick-create-rule#updateRuleTypeFields" }
|
||||||
|
} %>
|
||||||
<p class="mt-1 text-xs text-gray-500">Action to take when rule matches</p>
|
<p class="mt-1 text-xs text-gray-500">Action to take when rule matches</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,19 +349,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pattern-based Rule Fields -->
|
<!-- Pattern-based Rule Fields -->
|
||||||
<div id="pattern_fields" class="hidden space-y-4">
|
<div id="pattern_fields" data-quick-create-rule-target="patternFields" class="hidden space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :conditions, "Pattern/Conditions", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :conditions, "Pattern/Conditions", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_area :conditions, rows: 3,
|
<%= form.text_area :conditions, rows: 3,
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
placeholder: "Enter pattern or JSON conditions...",
|
placeholder: "Enter pattern or JSON conditions...",
|
||||||
id: "quick_conditions_field" %>
|
id: "quick_conditions_field",
|
||||||
<p class="mt-1 text-xs text-gray-500" id="pattern_help_text">Pattern will be used for matching</p>
|
data: { quick_create_rule_target: "conditionsField" } %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500" id="pattern_help_text" data-quick-create-rule-target="helpText">Pattern will be used for matching</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rate Limit Fields -->
|
<!-- Rate Limit Fields -->
|
||||||
<div id="rate_limit_fields" class="hidden space-y-4">
|
<div id="rate_limit_fields" data-quick-create-rule-target="rateLimitFields" class="hidden space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag :rate_limit, "Request Limit", class: "block text-sm font-medium text-gray-700" %>
|
<%= label_tag :rate_limit, "Request Limit", class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -363,7 +382,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Redirect Fields -->
|
<!-- Redirect Fields -->
|
||||||
<div id="redirect_fields" class="hidden space-y-4">
|
<div id="redirect_fields" data-quick-create-rule-target="redirectFields" class="hidden space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag :redirect_url, "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
<%= label_tag :redirect_url, "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= text_field_tag :redirect_url,
|
<%= text_field_tag :redirect_url,
|
||||||
@@ -405,7 +424,7 @@
|
|||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="flex justify-end space-x-3 pt-4 border-t border-blue-200">
|
<div class="flex justify-end space-x-3 pt-4 border-t border-blue-200">
|
||||||
<button type="button" onclick="toggleQuickCreateRule()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
<button type="button" data-action="click->quick-create-rule#toggle" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<%= form.submit "Create Rule", class: "px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700" %>
|
<%= form.submit "Create Rule", class: "px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700" %>
|
||||||
@@ -532,7 +551,7 @@
|
|||||||
|
|
||||||
<!-- Recent Events -->
|
<!-- Recent Events -->
|
||||||
<% if @related_events.any? %>
|
<% if @related_events.any? %>
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg" data-controller="timeline" data-timeline-mode-value="events">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Recent Events (<%= @related_events.count %>)</h3>
|
<h3 class="text-lg font-medium text-gray-900">Recent Events (<%= @related_events.count %>)</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -549,15 +568,22 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<% @related_events.first(20).each do |event| %>
|
<% @related_events.first(20).each do |event| %>
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50 cursor-pointer" onclick="window.location='<%= event_path(event) %>'">
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
<%= event.timestamp.strftime("%H:%M:%S") %>
|
<div class="text-gray-900" data-timeline-target="timestamp" data-iso="<%= event.timestamp.iso8601 %>">
|
||||||
|
<%= event.timestamp.strftime("%H:%M:%S") %>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500" data-timeline-target="date" data-iso="<%= event.timestamp.iso8601 %>">
|
||||||
|
<%= event.timestamp.strftime("%Y-%m-%d") %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||||
<%= event.ip_address %>
|
<%= event.ip_address %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="px-6 py-4 text-sm text-gray-900">
|
||||||
<%= event.request_path || "-" %>
|
<div class="max-w-md break-all">
|
||||||
|
<%= event.request_path || "-" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= event.waf_action == 'deny' ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' %>">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= event.waf_action == 'deny' ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' %>">
|
||||||
@@ -565,9 +591,34 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-900">
|
<td class="px-6 py-4 text-sm text-gray-900">
|
||||||
<div class="truncate max-w-xs" title="<%= event.user_agent %>">
|
<% if event.user_agent.present? %>
|
||||||
<%= event.user_agent&.truncate(50) || "-" %>
|
<% ua = parse_user_agent(event.user_agent) %>
|
||||||
</div>
|
<div class="space-y-0.5" title="<%= ua[:raw] %>">
|
||||||
|
<div class="font-medium text-gray-900">
|
||||||
|
<%= ua[:name] if ua[:name].present? %>
|
||||||
|
<% if ua[:version].present? && ua[:name].present? %>
|
||||||
|
<span class="text-gray-500 font-normal"><%= ua[:version] %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if ua[:os_name].present? %>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<%= ua[:os_name] %>
|
||||||
|
<% if ua[:os_version].present? %>
|
||||||
|
<%= ua[:os_version] %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if ua[:bot] %>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">
|
||||||
|
🤖 <%= ua[:bot_name] || 'Bot' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -578,85 +629,3 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function toggleQuickCreateRule() {
|
|
||||||
const formDiv = document.getElementById('quick_create_rule');
|
|
||||||
formDiv.classList.toggle('hidden');
|
|
||||||
|
|
||||||
// Reset form when hiding
|
|
||||||
if (formDiv.classList.contains('hidden')) {
|
|
||||||
resetQuickCreateForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRuleTypeFields() {
|
|
||||||
const ruleType = document.getElementById('quick_rule_type_select').value;
|
|
||||||
const action = document.querySelector('select[name="rule[action]"]').value;
|
|
||||||
|
|
||||||
// Hide all optional fields
|
|
||||||
document.getElementById('pattern_fields').classList.add('hidden');
|
|
||||||
document.getElementById('rate_limit_fields').classList.add('hidden');
|
|
||||||
document.getElementById('redirect_fields').classList.add('hidden');
|
|
||||||
|
|
||||||
// Show relevant fields based on rule type
|
|
||||||
if (['path_pattern', 'header_pattern', 'query_pattern', 'body_signature'].includes(ruleType)) {
|
|
||||||
document.getElementById('pattern_fields').classList.remove('hidden');
|
|
||||||
updatePatternHelpText(ruleType);
|
|
||||||
} else if (ruleType === 'rate_limit') {
|
|
||||||
document.getElementById('rate_limit_fields').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show redirect fields if action is redirect
|
|
||||||
if (action === 'redirect') {
|
|
||||||
document.getElementById('redirect_fields').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePatternHelpText(ruleType) {
|
|
||||||
const helpText = document.getElementById('pattern_help_text');
|
|
||||||
const conditionsField = document.getElementById('quick_conditions_field');
|
|
||||||
|
|
||||||
switch(ruleType) {
|
|
||||||
case 'path_pattern':
|
|
||||||
helpText.textContent = 'Regex pattern to match URL paths (e.g.,\\.env$|wp-admin|phpmyadmin)';
|
|
||||||
conditionsField.placeholder = 'Example: \\.env$|\\.git|config\\.php|wp-admin';
|
|
||||||
break;
|
|
||||||
case 'header_pattern':
|
|
||||||
helpText.textContent = 'JSON with header_name and pattern (e.g., {"header_name": "User-Agent", "pattern": "bot.*"})';
|
|
||||||
conditionsField.placeholder = 'Example: {"header_name": "User-Agent", "pattern": ".*[Bb]ot.*"}';
|
|
||||||
break;
|
|
||||||
case 'query_pattern':
|
|
||||||
helpText.textContent = 'Regex pattern to match query parameters (e.g., union.*select|<script)';
|
|
||||||
conditionsField.placeholder = 'Example: (?:union|select|insert|update|delete).*\\s+(?:union|select)';
|
|
||||||
break;
|
|
||||||
case 'body_signature':
|
|
||||||
helpText.textContent = 'Regex pattern to match request body content (e.g., OR 1=1|<script)';
|
|
||||||
conditionsField.placeholder = 'Example: (?:OR\\s+1\\s*=\\s*1|AND\\s+1\\s*=\\s*1|UNION\\s+SELECT)';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetQuickCreateForm() {
|
|
||||||
const form = document.querySelector('#quick_create_rule form');
|
|
||||||
if (form) {
|
|
||||||
form.reset();
|
|
||||||
// Reset rule type to default
|
|
||||||
document.getElementById('quick_rule_type_select').value = 'network';
|
|
||||||
toggleRuleTypeFields();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the form visibility state
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Set up action change listener to show/hide redirect fields
|
|
||||||
const actionSelect = document.querySelector('select[name="rule[action]"]');
|
|
||||||
if (actionSelect) {
|
|
||||||
actionSelect.addEventListener('change', function() {
|
|
||||||
toggleRuleTypeFields();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize field visibility
|
|
||||||
toggleRuleTypeFields();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
60
app/views/settings/index.html.erb
Normal file
60
app/views/settings/index.html.erb
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<% content_for :title, "Settings" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Manage system configuration and API keys</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Form -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">API Configuration</h3>
|
||||||
|
|
||||||
|
<!-- ipapi.is API Key -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<%= form_with url: settings_path, method: :patch, class: "space-y-4" do |f| %>
|
||||||
|
<%= hidden_field_tag :key, 'ipapi_key' %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="ipapi_key" class="block text-sm font-medium text-gray-700">
|
||||||
|
ipapi.is API Key
|
||||||
|
</label>
|
||||||
|
<div class="mt-1 flex rounded-md shadow-sm">
|
||||||
|
<%= text_field_tag :value,
|
||||||
|
@settings['ipapi_key']&.value || ENV['IPAPI_KEY'],
|
||||||
|
class: "flex-1 min-w-0 block w-full px-3 py-2 rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "Enter your ipapi.is API key" %>
|
||||||
|
<%= f.submit "Update", class: "ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
<% if @settings['ipapi_key']&.value.present? %>
|
||||||
|
<span class="text-green-600">✓ Configured in database</span>
|
||||||
|
<% elsif ENV['IPAPI_KEY'].present? %>
|
||||||
|
<span class="text-yellow-600">Using environment variable (IPAPI_KEY)</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-red-600">ipapi.is not active</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
Get your API key from <a href="https://ipapi.is/" target="_blank" class="text-blue-600 hover:text-blue-800">ipapi.is</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Future Settings Section -->
|
||||||
|
<div class="mt-6 bg-gray-50 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-2">Additional Settings</h3>
|
||||||
|
<p class="text-sm text-gray-500">More configuration options will be added here as needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -77,4 +77,6 @@ Rails.application.configure do
|
|||||||
# config.generators.apply_rubocop_autocorrect_after_generate!
|
# config.generators.apply_rubocop_autocorrect_after_generate!
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :solid_queue
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||||
|
# Don't store finished jobs - we don't need job history, saves DB space
|
||||||
|
config.solid_queue.preserve_finished_jobs = false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ Rails.application.configure do
|
|||||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :solid_queue
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||||
|
# Don't store finished jobs - we don't need job history, saves DB space
|
||||||
|
config.solid_queue.preserve_finished_jobs = false
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# Ignore bad email addresses and do not raise email delivery errors.
|
||||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
|
|||||||
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
||||||
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||||
|
|
||||||
|
# Tom Select for enhanced multi-select
|
||||||
|
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"
|
||||||
|
pin "tom-select-css", to: "https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css"
|
||||||
|
|||||||
156
config/initializers/sentry.rb
Normal file
156
config/initializers/sentry.rb
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Sentry configuration for error tracking and performance monitoring
|
||||||
|
# Only initializes if SENTRY_DSN is configured
|
||||||
|
|
||||||
|
return unless ENV['SENTRY_DSN'].present?
|
||||||
|
|
||||||
|
require 'sentry-rails'
|
||||||
|
|
||||||
|
Sentry.init do |config|
|
||||||
|
config.dsn = ENV['SENTRY_DSN']
|
||||||
|
|
||||||
|
# Configure sampling for production (lower in production, higher in staging)
|
||||||
|
config.traces_sample_rate = case Rails.env
|
||||||
|
when 'production' then ENV.fetch('SENTRY_TRACES_SAMPLE_RATE', '0.05').to_f
|
||||||
|
when 'staging' then ENV.fetch('SENTRY_TRACES_SAMPLE_RATE', '0.2').to_f
|
||||||
|
else ENV.fetch('SENTRY_TRACES_SAMPLE_RATE', '0.1').to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
# Enable breadcrumbs for better debugging
|
||||||
|
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
|
||||||
|
|
||||||
|
# Send PII data (like IPs) to Sentry for debugging (disable in production if needed)
|
||||||
|
config.send_default_pii = ENV.fetch('SENTRY_SEND_PII', 'false') == 'true'
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
config.environment = Rails.env
|
||||||
|
|
||||||
|
# Configure release info from Docker tag or Git
|
||||||
|
if ENV['GIT_COMMIT_SHA']
|
||||||
|
config.release = ENV['GIT_COMMIT_SHA'][0..7]
|
||||||
|
elsif ENV['APP_VERSION']
|
||||||
|
config.release = ENV['APP_VERSION']
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set server name for multi-instance environments
|
||||||
|
config.server_name = ENV.fetch('SERVER_NAME', 'baffle-agent')
|
||||||
|
|
||||||
|
# Filter out certain errors to reduce noise and add tags
|
||||||
|
config.before_send = lambda do |event, hint|
|
||||||
|
# Filter out 404 errors and other expected HTTP errors
|
||||||
|
if event.contexts.dig(:response, :status_code) == 404
|
||||||
|
nil
|
||||||
|
# Filter out validation errors in development
|
||||||
|
elsif Rails.env.development? && event.exception&.message&.include?("Validation failed")
|
||||||
|
nil
|
||||||
|
# Filter out specific noisy exceptions
|
||||||
|
elsif %w[ActionController::RoutingError
|
||||||
|
ActionController::InvalidAuthenticityToken
|
||||||
|
ActionController::UnknownFormat
|
||||||
|
ActionDispatch::Http::Parameters::ParseError].include?(event.exception&.class&.name)
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
# Add tags for better filtering in Sentry
|
||||||
|
event.tags.merge!({
|
||||||
|
ruby_version: RUBY_VERSION,
|
||||||
|
rails_version: Rails.version,
|
||||||
|
environment: Rails.env
|
||||||
|
})
|
||||||
|
event
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configure exception class exclusion
|
||||||
|
config.excluded_exceptions += [
|
||||||
|
'ActionController::RoutingError',
|
||||||
|
'ActionController::InvalidAuthenticityToken',
|
||||||
|
'CGI::Session::CookieStore::TamperedWithCookie',
|
||||||
|
'ActionController::UnknownFormat',
|
||||||
|
'ActionDispatch::Http::Parameters::ParseError',
|
||||||
|
'Mongoid::Errors::DocumentNotFound'
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# SolidQueue monitoring will be automatically handled by sentry-solid_queue gem
|
||||||
|
|
||||||
|
# Add correlation ID to Sentry context
|
||||||
|
ActiveSupport::Notifications.subscribe('action_controller.process_action') do |name, start, finish, id, payload|
|
||||||
|
controller = payload[:controller]
|
||||||
|
action = payload[:action]
|
||||||
|
request_id = payload[:request]&.request_id
|
||||||
|
|
||||||
|
if controller && action && request_id
|
||||||
|
Sentry.set_context(:request, {
|
||||||
|
correlation_id: request_id,
|
||||||
|
controller: controller.controller_name,
|
||||||
|
action: action.action_name,
|
||||||
|
ip: request&.remote_ip
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add ActiveJob context to all job transactions
|
||||||
|
ActiveSupport::Notifications.subscribe('perform.active_job') do |name, start, finish, id, payload|
|
||||||
|
job = payload[:job]
|
||||||
|
|
||||||
|
Sentry.configure_scope do |scope|
|
||||||
|
scope.set_tag(:job_class, job.class.name)
|
||||||
|
scope.set_tag(:job_queue, job.queue_name)
|
||||||
|
scope.set_tag(:job_id, job.job_id)
|
||||||
|
|
||||||
|
scope.set_context(:job, {
|
||||||
|
job_id: job.job_id,
|
||||||
|
job_class: job.class.name,
|
||||||
|
queue_name: job.queue_name,
|
||||||
|
arguments: job.arguments.to_s,
|
||||||
|
enqueued_at: job.enqueued_at,
|
||||||
|
executions: job.executions
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Monitor SolidQueue job failures
|
||||||
|
ActiveSupport::Notifications.subscribe('solid_queue.error') do |name, start, finish, id, payload|
|
||||||
|
job = payload[:job]
|
||||||
|
error = payload[:error]
|
||||||
|
|
||||||
|
Sentry.with_scope do |scope|
|
||||||
|
scope.set_tag(:job_class, job.class_name)
|
||||||
|
scope.set_tag(:job_queue, job.queue_name)
|
||||||
|
scope.set_context(:job, {
|
||||||
|
job_id: job.active_job_id,
|
||||||
|
arguments: job.arguments.to_s,
|
||||||
|
queue_name: job.queue_name,
|
||||||
|
created_at: job.created_at
|
||||||
|
})
|
||||||
|
scope.set_context(:error, {
|
||||||
|
error_class: error.class.name,
|
||||||
|
error_message: error.message
|
||||||
|
})
|
||||||
|
|
||||||
|
Sentry.capture_exception(error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set user context when available
|
||||||
|
if defined?(Current) && Current.user
|
||||||
|
Sentry.set_user(id: Current.user.id, email: Current.user.email)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add application-specific context
|
||||||
|
app_version = begin
|
||||||
|
File.read(Rails.root.join('VERSION')).strip
|
||||||
|
rescue
|
||||||
|
ENV['APP_VERSION'] || ENV['GIT_COMMIT_SHA']&.[](0..7) || 'unknown'
|
||||||
|
end
|
||||||
|
|
||||||
|
Sentry.set_context('application', {
|
||||||
|
name: 'BaffleHub',
|
||||||
|
version: app_version,
|
||||||
|
environment: Rails.env,
|
||||||
|
database: ActiveRecord::Base.connection.adapter_name,
|
||||||
|
queue_adapter: Rails.application.config.active_job.queue_adapter
|
||||||
|
})
|
||||||
|
|
||||||
|
Rails.logger.info "Sentry configured for environment: #{Rails.env}"
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
# priority: 2
|
# priority: 2
|
||||||
# schedule: at 5am every day
|
# schedule: at 5am every day
|
||||||
|
|
||||||
production:
|
# No recurring tasks configured yet
|
||||||
clear_solid_queue_finished_jobs:
|
# (previously had clear_solid_queue_finished_jobs, but now preserve_finished_jobs: false in queue.yml)
|
||||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
||||||
schedule: every hour at minute 12
|
# Clean up failed jobs older than 1 day
|
||||||
|
cleanup_failed_jobs:
|
||||||
|
command: "SolidQueue::FailedExecution.where('created_at < ?', 1.day.ago).delete_all"
|
||||||
|
queue: background
|
||||||
|
schedule: every 6 hours
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ Rails.application.routes.draw do
|
|||||||
# Admin user management (admin only)
|
# Admin user management (admin only)
|
||||||
resources :users, only: [:index, :show, :edit, :update]
|
resources :users, only: [:index, :show, :edit, :update]
|
||||||
|
|
||||||
|
# Settings management (admin only)
|
||||||
|
resources :settings, only: [:index] do
|
||||||
|
collection do
|
||||||
|
patch :update
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# DSN management (admin only)
|
# DSN management (admin only)
|
||||||
resources :dsns do
|
resources :dsns do
|
||||||
member do
|
member do
|
||||||
@@ -44,7 +51,7 @@ Rails.application.routes.draw do
|
|||||||
root "analytics#index"
|
root "analytics#index"
|
||||||
|
|
||||||
# Event management
|
# Event management
|
||||||
resources :events, only: [:index]
|
resources :events, only: [:index, :show]
|
||||||
|
|
||||||
# Network range management
|
# Network range management
|
||||||
resources :network_ranges, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
|
resources :network_ranges, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
|
||||||
@@ -79,4 +86,11 @@ Rails.application.routes.draw do
|
|||||||
post :create_country
|
post :create_country
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GeoLite2 data import management (admin only)
|
||||||
|
resources :data_imports, only: [:index, :new, :create, :show, :destroy] do
|
||||||
|
member do
|
||||||
|
get :progress
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ test:
|
|||||||
|
|
||||||
local:
|
local:
|
||||||
service: Disk
|
service: Disk
|
||||||
root: <%= Rails.root.join("storage") %>
|
root: <%= Rails.root.join("storage", "uploads") %>
|
||||||
|
|
||||||
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
||||||
# amazon:
|
# amazon:
|
||||||
|
|||||||
13
config/tailwind.config.js
Normal file
13
config/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./app/helpers/**/*.rb',
|
||||||
|
'./app/javascript/**/*.js',
|
||||||
|
'./app/views/**/*.erb',
|
||||||
|
'./app/views/**/*.html.erb'
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class AddPoliciesEvaluatedAtToNetworkRanges < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :network_ranges, :policies_evaluated_at, :datetime
|
||||||
|
add_index :network_ranges, :policies_evaluated_at
|
||||||
|
end
|
||||||
|
end
|
||||||
11
db/migrate/20251111053159_create_settings.rb
Normal file
11
db/migrate/20251111053159_create_settings.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class CreateSettings < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :settings do |t|
|
||||||
|
t.string :key
|
||||||
|
t.string :value
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :settings, :key, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class ConvertAdditionalDataToNetworkData < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :network_ranges, :network_data, :jsonb, default: {}
|
||||||
|
add_index :network_ranges, :network_data, using: :gin
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user