Tidy up homepage and navigation

This commit is contained in:
Dan Milne
2025-11-09 20:58:13 +11:00
parent c9e2992fe0
commit 1f4428348d
56 changed files with 2822 additions and 955 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,133 @@
# frozen_string_literal: true
# AnalyticsController - Overview dashboard with statistics and charts
class AnalyticsController < ApplicationController
# All actions require authentication
def index
authorize :analytics, :index?
# Time period selector (default: last 24 hours)
@time_period = params[:period]&.to_sym || :day
@start_time = calculate_start_time(@time_period)
# Core statistics
@total_events = Event.where("timestamp >= ?", @start_time).count
@total_rules = Rule.enabled.count
@network_ranges_with_events = NetworkRange.with_events.count
@total_network_ranges = NetworkRange.count
# Event breakdown by action
@event_breakdown = Event.where("timestamp >= ?", @start_time)
.group(:waf_action)
.count
.transform_keys do |action_id|
case action_id
when 0 then 'allow'
when 1 then 'deny'
when 2 then 'redirect'
when 3 then 'challenge'
else 'unknown'
end
end
# Top countries by event count
@top_countries = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time)
.group("network_ranges.country")
.count
.sort_by { |_, count| -count }
.first(10)
# Top blocked IPs
@top_blocked_ips = Event.where("timestamp >= ?", @start_time)
.where(waf_action: 1) # deny action in enum
.group(:ip_address)
.count
.sort_by { |_, count| -count }
.first(10)
# Network range intelligence breakdown
@network_intelligence = {
datacenter_ranges: NetworkRange.datacenter.count,
vpn_ranges: NetworkRange.vpn.count,
proxy_ranges: NetworkRange.proxy.count,
total_ranges: NetworkRange.count
}
# Recent activity
@recent_events = Event.recent.limit(10)
@recent_rules = Rule.order(created_at: :desc).limit(5)
# System health indicators
@system_health = {
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
}
# Prepare data for charts
@chart_data = prepare_chart_data
respond_to do |format|
format.html
format.turbo_stream
end
end
private
def calculate_start_time(period)
case period
when :hour
1.hour.ago
when :day
24.hours.ago
when :week
1.week.ago
when :month
1.month.ago
else
24.hours.ago
end
end
def prepare_chart_data
# Events over time (hourly buckets for last 24 hours)
events_by_hour = Event.where("timestamp >= ?", 24.hours.ago)
.group("DATE_TRUNC('hour', timestamp)")
.count
# Convert to chart format
timeline_data = (0..23).map do |hour_ago|
hour_time = hour_ago.hours.ago
hour_key = hour_time.strftime("%Y-%m-%d %H:00:00")
{
time: hour_time.strftime("%H:00"),
total: events_by_hour[hour_key] || 0
}
end.reverse
# Action distribution for pie chart
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
{
timeline: timeline_data,
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
end

View File

@@ -4,17 +4,16 @@ class Api::EventsController < ApplicationController
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
allow_unauthenticated_access # Skip normal session auth, use DSN auth instead allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
# POST /api/:project_id/events # POST /api/events
def create def create
project = authenticate_project! dsn = authenticate_dsn!
return head :not_found unless project return head :not_found unless dsn
# Parse the incoming WAF event data # Parse the incoming WAF event data
event_data = parse_event_data(request) event_data = parse_event_data(request)
# Create event asynchronously # Create event asynchronously
ProcessWafEventJob.perform_later( ProcessWafEventJob.perform_later(
project_id: project.id,
event_data: event_data, event_data: event_data,
headers: extract_serializable_headers(request) headers: extract_serializable_headers(request)
) )
@@ -64,8 +63,8 @@ class Api::EventsController < ApplicationController
private private
def authenticate_project! def authenticate_dsn!
DsnAuthenticationService.authenticate(request, params[:project_id]) DsnAuthenticationService.authenticate(request)
end end
def parse_event_data(request) def parse_event_data(request)

View File

@@ -7,11 +7,11 @@ module Api
# These endpoints are kept for administrative/debugging purposes only # These endpoints are kept for administrative/debugging purposes only
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
allow_unauthenticated_access # Skip normal session auth, use project key auth instead allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
before_action :authenticate_project! before_action :authenticate_dsn!
before_action :check_project_enabled before_action :check_dsn_enabled
# GET /api/:public_key/rules/version # GET /api/rules/version
# Quick version check - returns latest updated_at timestamp # Quick version check - returns latest updated_at timestamp
def version def version
current_sampling = HubLoad.current_sampling current_sampling = HubLoad.current_sampling
@@ -24,9 +24,9 @@ module Api
} }
end end
# GET /api/:public_key/rules?since=1730646186 # GET /api/rules?since=1730646186
# Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds) # Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds)
# GET /api/:public_key/rules # GET /api/rules
# Full sync - returns all active rules # Full sync - returns all active rules
def index def index
rules = if params[:since].present? rules = if params[:since].present?
@@ -52,20 +52,20 @@ module Api
private private
def authenticate_project! def authenticate_dsn!
public_key = params[:public_key] || params[:project_id] @dsn = DsnAuthenticationService.authenticate(request)
@project = Project.find_by(public_key: public_key) unless @dsn
render json: { error: "Invalid DSN key" }, status: :unauthorized
unless @project
render json: { error: "Invalid project key" }, status: :unauthorized
return return
end end
rescue DsnAuthenticationService::AuthenticationError => e
render json: { error: e.message }, status: :unauthorized
end end
def check_project_enabled def check_dsn_enabled
unless @project.enabled? unless @dsn.enabled?
render json: { error: "Project is disabled" }, status: :forbidden render json: { error: "DSN is disabled" }, status: :forbidden
end end
end end

View File

@@ -0,0 +1,95 @@
# frozen_string_literal: true
class DsnsController < ApplicationController
before_action :require_authentication
before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable]
before_action :authorize_dsn_management, except: [:index, :show]
# GET /dsns
def index
@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
# GET /dsns/new
def new
authorize Dsn
@dsn = Dsn.new
end
# POST /dsns
def create
authorize Dsn
@dsn = Dsn.new(dsn_params)
if @dsn.save
redirect_to @dsn, notice: 'DSN was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
# GET /dsns/:id
def show
end
# GET /dsns/:id/edit
def edit
end
# PATCH/PUT /dsns/:id
def update
if @dsn.update(dsn_params)
redirect_to @dsn, notice: 'DSN was successfully updated.'
else
render :edit, status: :unprocessable_entity
end
end
# POST /dsns/:id/disable
def disable
@dsn.update!(enabled: false)
redirect_to @dsn, notice: 'DSN was disabled.'
end
# POST /dsns/:id/enable
def enable
@dsn.update!(enabled: true)
redirect_to @dsn, notice: 'DSN was enabled.'
end
private
def set_dsn
@dsn = Dsn.find(params[:id])
end
def dsn_params
params.require(:dsn).permit(:name, :enabled)
end
def authorize_dsn_management
# Only allow admins to manage DSNs
redirect_to root_path, alert: 'Access denied' unless Current.user&.admin?
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

View File

@@ -1,21 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class EventsController < ApplicationController class EventsController < ApplicationController
before_action :set_project
def index def index
@events = @project.events.order(timestamp: :desc) @events = Event.order(timestamp: :desc)
Rails.logger.debug "Found project? #{@project.name} / #{@project.events.count} / #{@events.count}" Rails.logger.debug "Found #{@events.count} total events"
Rails.logger.debug "Action: #{params[:waf_action]}" Rails.logger.debug "Action: #{params[:waf_action]}"
# Apply filters # Apply filters
@events = @events.by_ip(params[:ip]) if params[:ip].present? @events = @events.by_ip(params[:ip]) if params[:ip].present?
@events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present? @events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present?
@events = @events.where(country_code: params[:country]) if params[:country].present? @events = @events.where(country_code: params[:country]) if params[:country].present?
Rails.logger.debug "after filter #{@project.name} / #{@project.events.count} / #{@events.count}" Rails.logger.debug "Events count after filtering: #{@events.count}"
# Debug info # Debug info
Rails.logger.debug "Events count before pagination: #{@events.count}" Rails.logger.debug "Events count before pagination: #{@events.count}"
Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})"
# Paginate # Paginate
@pagy, @events = pagy(@events, items: 50) @pagy, @events = pagy(@events, items: 50)
@@ -23,11 +22,4 @@ class EventsController < ApplicationController
Rails.logger.debug "Events count after pagination: #{@events.count}" Rails.logger.debug "Events count after pagination: #{@events.count}"
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages" Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
end end
private
def set_project
@project = Project.find(params[:project_id]) || Project.find_by(slug: params[:project_id])
redirect_to projects_path, alert: "Project not found" unless @project
end
end end

View File

@@ -7,11 +7,10 @@
class NetworkRangesController < ApplicationController class NetworkRangesController < ApplicationController
# Follow proper before_action order: # Follow proper before_action order:
# 1. Authentication/Authorization # 1. Authentication/Authorization
allow_unauthenticated_access only: [:index, :show, :lookup] # All actions require authentication
# 2. Resource loading # 2. Resource loading
before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich] before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich]
before_action :set_project, only: [:index, :show]
# GET /network_ranges # GET /network_ranges
def index def index
@@ -158,15 +157,6 @@ class NetworkRangesController < ApplicationController
@network_range = NetworkRange.find_by!(network: cidr) @network_range = NetworkRange.find_by!(network: cidr)
end end
def set_project
# For now, use the first project or create a default one
@project = Project.first || Project.create!(
name: 'Default Project',
slug: 'default',
public_key: SecureRandom.hex(32)
)
end
def network_range_params def network_range_params
params.require(:network_range).permit( params.require(:network_range).permit(
:network, :network,
@@ -204,18 +194,33 @@ class NetworkRangesController < ApplicationController
end end
def calculate_traffic_stats(network_range) def calculate_traffic_stats(network_range)
# Calculate traffic statistics for this network range # Use the cached events_count for total requests (much more performant)
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") # For detailed breakdown, we still need to query but we can optimize with a limit
.where("network_ranges.id = ?", network_range.id) if network_range.events_count > 0
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.id = ?", network_range.id)
.limit(1000) # Limit the sample for performance
{ {
total_requests: events.count, total_requests: network_range.events_count, # Use cached count
unique_ips: events.distinct.count(:ip_address), unique_ips: events.distinct.count(:ip_address),
blocked_requests: events.blocked.count, blocked_requests: events.blocked.count,
allowed_requests: events.allowed.count, allowed_requests: events.allowed.count,
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10), top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5), top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
recent_activity: events.recent.limit(20) recent_activity: events.recent.limit(20)
} }
else
# No events - return empty stats
{
total_requests: 0,
unique_ips: 0,
blocked_requests: 0,
allowed_requests: 0,
top_paths: {},
top_user_agents: {},
recent_activity: []
}
end
end end
end end

View File

@@ -1,99 +0,0 @@
# frozen_string_literal: true
class ProjectsController < ApplicationController
before_action :set_project, only: [:show, :edit, :update, :events, :analytics]
def index
@projects = Project.order(created_at: :desc)
end
def show
@recent_events = @project.recent_events(limit: 10)
@event_count = @project.event_count(24.hours.ago)
@blocked_count = @project.blocked_count(24.hours.ago)
@waf_status = @project.waf_status
end
def new
@project = Project.new
end
def create
@project = Project.new(project_params)
if @project.save
redirect_to @project, notice: "Project was successfully created. Use this DSN for your baffle-agent: #{@project.dsn}"
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @project.update(project_params)
redirect_to @project, notice: "Project was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
def events
@events = @project.events.recent.includes(:project)
# Apply filters
@events = @events.by_ip(params[:ip]) if params[:ip].present?
@events = @events.by_waf_action(params[:action]) if params[:action].present?
@events = @events.where(country_code: params[:country]) if params[:country].present?
# Debug info
Rails.logger.debug "Events count before pagination: #{@events.count}"
Rails.logger.debug "Project: #{@project&.name} (ID: #{@project&.id})"
# Paginate
@pagy, @events = pagy(@events, items: 50)
Rails.logger.debug "Events count after pagination: #{@events.count}"
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
end
def analytics
@time_range = params[:time_range]&.to_i || 24 # hours
# Basic analytics
@total_events = @project.event_count(@time_range.hours.ago)
@blocked_events = @project.blocked_count(@time_range.hours.ago)
@allowed_events = @project.allowed_count(@time_range.hours.ago)
# Top blocked IPs
@top_blocked_ips = @project.top_blocked_ips(limit: 10, time_range: @time_range.hours.ago)
# Country distribution
@country_stats = @project.events
.where(timestamp: @time_range.hours.ago..Time.current)
.where.not(country_code: nil)
.group(:country_code)
.select('country_code, COUNT(*) as count')
.order('count DESC')
.limit(10)
# Action distribution
@action_stats = @project.events
.where(timestamp: @time_range.hours.ago..Time.current)
.group(:waf_action)
.select('waf_action as action, COUNT(*) as count')
.order('count DESC')
end
private
def set_project
@project = Project.find_by(slug: params[:id]) || Project.find_by(id: params[:id])
redirect_to projects_path, alert: "Project not found" unless @project
end
def project_params
params.require(:project).permit(:name, :enabled, settings: {})
end
end

View File

@@ -1,4 +1,5 @@
class RegistrationsController < ApplicationController class RegistrationsController < ApplicationController
layout "authentication"
allow_unauthenticated_access only: [:new, :create] allow_unauthenticated_access only: [:new, :create]
before_action :ensure_no_users_exist, only: [:new, :create] before_action :ensure_no_users_exist, only: [:new, :create]

View File

@@ -3,15 +3,14 @@
class RulesController < ApplicationController class RulesController < ApplicationController
# Follow proper before_action order: # Follow proper before_action order:
# 1. Authentication/Authorization # 1. Authentication/Authorization
allow_unauthenticated_access only: [:index, :show] # All actions require authentication
# 2. Resource loading # 2. Resource loading
before_action :set_rule, only: [:show, :edit, :update, :disable, :enable] before_action :set_rule, only: [:show, :edit, :update, :disable, :enable]
before_action :set_project, only: [:index, :show]
# GET /rules # GET /rules
def index def index
@rules = policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc) @pagy, @rules = pagy(policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc))
@rule_types = Rule::RULE_TYPES @rule_types = Rule::RULE_TYPES
@actions = Rule::ACTIONS @actions = Rule::ACTIONS
end end
@@ -43,6 +42,9 @@ class RulesController < ApplicationController
@rule_types = Rule::RULE_TYPES @rule_types = Rule::RULE_TYPES
@actions = Rule::ACTIONS @actions = Rule::ACTIONS
# Process additional form data for quick create
process_quick_create_parameters
# Handle network range creation if CIDR is provided # Handle network range creation if CIDR is provided
if params[:cidr].present? && @rule.network_rule? if params[:cidr].present? && @rule.network_rule?
network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range| network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range|
@@ -53,8 +55,17 @@ class RulesController < ApplicationController
@rule.network_range = network_range @rule.network_range = network_range
end end
# Calculate priority automatically based on rule type
calculate_rule_priority
if @rule.save if @rule.save
redirect_to @rule, notice: 'Rule was successfully created.' # For quick create from NetworkRange page, redirect back to network range
if params[:rule][:network_range_id].present? && request.referer&.include?('/network_ranges/')
network_range = NetworkRange.find(params[:rule][:network_range_id])
redirect_to network_range, notice: 'Rule was successfully created.'
else
redirect_to @rule, notice: 'Rule was successfully created.'
end
else else
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
@@ -122,13 +133,236 @@ class RulesController < ApplicationController
params.require(:rule).permit(permitted) params.require(:rule).permit(permitted)
end end
def set_project def calculate_rule_priority
# For now, use the first project or create a default one return unless @rule
@project = Project.first || Project.create!(
name: 'Default Project', case @rule.rule_type
slug: 'default', when 'network'
public_key: SecureRandom.hex(32) # For network rules, priority based on prefix specificity
) if @rule.network_range
prefix = @rule.network_range.prefix_length
@rule.priority = case prefix
when 32 then 200 # /32 single IP
when 31 then 190
when 30 then 180
when 29 then 170
when 28 then 160
when 27 then 150
when 26 then 140
when 25 then 130
when 24 then 120
when 23 then 110
when 22 then 100
when 21 then 90
when 20 then 80
when 19 then 70
when 18 then 60
when 17 then 50
when 16 then 40
when 15 then 30
when 14 then 20
when 13 then 10
else 0
end
else
@rule.priority = 100 # Default for network rules without range
end
when 'protocol_violation'
@rule.priority = 95
when 'method_enforcement'
@rule.priority = 90
when 'path_pattern'
@rule.priority = 85
when 'header_pattern', 'query_pattern'
@rule.priority = 80
when 'body_signature'
@rule.priority = 75
when 'rate_limit'
@rule.priority = 70
when 'composite'
@rule.priority = 65
else
@rule.priority = 50 # Default priority
end
end
def process_quick_create_parameters
return unless @rule
# Handle rate limiting parameters
if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present?
rate_limit_data = {
limit: params[:rate_limit].to_i,
window_seconds: params[:rate_window].to_i,
scope: 'per_ip'
}
# Update conditions with rate limit data
@rule.conditions ||= {}
@rule.conditions.merge!(rate_limit_data)
end end
end # Handle redirect URL
if @rule.action == 'redirect' && params[:redirect_url].present?
@rule.metadata ||= {}
if @rule.metadata.is_a?(String)
begin
@rule.metadata = JSON.parse(@rule.metadata)
rescue JSON::ParserError
@rule.metadata = {}
end
end
@rule.metadata.merge!({
redirect_url: params[:redirect_url],
redirect_status: 302
})
end
# Parse metadata if it's a string that looks like JSON
if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{')
begin
@rule.metadata = JSON.parse(@rule.metadata)
rescue JSON::ParserError
# Keep as string if not valid JSON
end
end
# Add reason to metadata if provided
if params.dig(:rule, :metadata).present?
if @rule.metadata.is_a?(Hash)
@rule.metadata['reason'] = params[:rule][:metadata]
else
@rule.metadata = { 'reason' => params[:rule][:metadata] }
end
end
end
private
def set_rule
@rule = Rule.find(params[:id])
end
def rule_params
permitted = [
:rule_type,
:action,
:metadata,
:expires_at,
:enabled,
:source,
:network_range_id
]
# Only include conditions for non-network rules
if params[:rule][:rule_type] != 'network'
permitted << :conditions
end
params.require(:rule).permit(permitted)
end
def calculate_rule_priority
return unless @rule
case @rule.rule_type
when 'network'
# For network rules, priority based on prefix specificity
if @rule.network_range
prefix = @rule.network_range.prefix_length
@rule.priority = case prefix
when 32 then 200 # /32 single IP
when 31 then 190
when 30 then 180
when 29 then 170
when 28 then 160
when 27 then 150
when 26 then 140
when 25 then 130
when 24 then 120
when 23 then 110
when 22 then 100
when 21 then 90
when 20 then 80
when 19 then 70
when 18 then 60
when 17 then 50
when 16 then 40
when 15 then 30
when 14 then 20
when 13 then 10
else 0
end
else
@rule.priority = 100 # Default for network rules without range
end
when 'protocol_violation'
@rule.priority = 95
when 'method_enforcement'
@rule.priority = 90
when 'path_pattern'
@rule.priority = 85
when 'header_pattern', 'query_pattern'
@rule.priority = 80
when 'body_signature'
@rule.priority = 75
when 'rate_limit'
@rule.priority = 70
when 'composite'
@rule.priority = 65
else
@rule.priority = 50 # Default priority
end
end
def process_quick_create_parameters
return unless @rule
# Handle rate limiting parameters
if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present?
rate_limit_data = {
limit: params[:rate_limit].to_i,
window_seconds: params[:rate_window].to_i,
scope: 'per_ip'
}
# Update conditions with rate limit data
@rule.conditions ||= {}
@rule.conditions.merge!(rate_limit_data)
end
# Handle redirect URL
if @rule.action == 'redirect' && params[:redirect_url].present?
@rule.metadata ||= {}
if @rule.metadata.is_a?(String)
begin
@rule.metadata = JSON.parse(@rule.metadata)
rescue JSON::ParserError
@rule.metadata = {}
end
end
@rule.metadata.merge!({
redirect_url: params[:redirect_url],
redirect_status: 302
})
end
# Parse metadata if it's a string that looks like JSON
if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{')
begin
@rule.metadata = JSON.parse(@rule.metadata)
rescue JSON::ParserError
# Keep as string if not valid JSON
end
end
# Add reason to metadata if provided
if params.dig(:rule, :metadata).present?
if @rule.metadata.is_a?(Hash)
@rule.metadata['reason'] = params[:rule][:metadata]
else
@rule.metadata = { 'reason' => params[:rule][:metadata] }
end
end
end
end

View File

@@ -1,4 +1,5 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
layout "authentication"
allow_unauthenticated_access only: %i[ new create ] allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

View File

@@ -1,3 +1,92 @@
module ApplicationHelper module ApplicationHelper
include Pagy::Frontend if defined?(Pagy) include Pagy::Frontend if defined?(Pagy)
# Helper method for time period selector styling
def time_period_class(period)
base_classes = "px-4 py-2 text-sm font-medium border-r border-gray-300 last:border-r-0"
if @time_period == period
base_classes + " bg-blue-600 text-white"
else
base_classes + " text-gray-700 hover:bg-gray-50"
end
end
# Custom pagination with Tailwind CSS styling
def pagy_nav_tailwind(pagy, pagy_id: nil)
return '' if pagy.pages <= 1
html = '<nav class="flex items-center justify-between" aria-label="Pagination">'
html += '<div class="flex-1 flex justify-between sm:hidden">'
if pagy.prev
html += link_to('← Previous', pagy_url_for(pagy, pagy.prev),
class: 'relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50')
else
html += '<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-100 cursor-not-allowed">← Previous</span>'
end
if pagy.next
html += link_to('Next →', pagy_url_for(pagy, pagy.next),
class: 'ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50')
else
html += '<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-100 cursor-not-allowed">Next →</span>'
end
html += '</div>'
html += '<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">'
html += '<div>'
html += '<p class="text-sm text-gray-700">'
html += 'Showing'
html += " <span class=\"font-medium\">#{pagy.from}</span>"
html += ' to'
html += " <span class=\"font-medium\">#{pagy.to}</span>"
html += ' of'
html += " <span class=\"font-medium\">#{pagy.count}</span>"
html += ' results'
html += '</p>'
html += '</div>'
html += '<div>'
html += '<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">'
# Previous button
if pagy.prev
html += link_to('←', pagy_url_for(pagy, pagy.prev),
class: 'relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50')
else
html += '<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">←</span>'
end
# Page numbers
pagy.series.each do |item|
case item
when Integer
if item == pagy.page
html += "<span aria-current=\"page\" class=\"relative inline-flex items-center px-4 py-2 border border-blue-500 bg-blue-500 text-sm font-medium text-white\">#{item}</span>"
else
html += link_to(item, pagy_url_for(pagy, item),
class: 'relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50')
end
when String
html += "<span class=\"relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700\">#{item}</span>"
when :gap
html += "<span class=\"relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700\">...</span>"
end
end
# Next button
if pagy.next
html += link_to('→', pagy_url_for(pagy, pagy.next),
class: 'relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50')
else
html += '<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">→</span>'
end
html += '</nav>'
html += '</div>'
html += '</div>'
html += '</nav>'
raw html
end
end end

View File

@@ -0,0 +1,33 @@
# frozen_string_literal: true
module NavigationHelper
def nav_link_class(path)
current = request.path == path || (path == root_path && request.path == events_path && !request.path.include?('/network_ranges') && !request.path.include?('/rules'))
if current
"bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium"
else
"text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
end
end
def mobile_nav_link_class(path)
current = request.path == path || (path == root_path && request.path == events_path && !request.path.include?('/network_ranges') && !request.path.include?('/rules'))
if current
"bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium"
else
"text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium transition-colors"
end
end
def time_period_class(period)
base_class = "px-4 py-2 text-sm font-medium border-r last:border-r-0 transition-colors"
if @time_period == period
base_class + " bg-blue-600 text-white"
else
base_class + " bg-white text-gray-700 hover:bg-gray-50"
end
end
end

View File

@@ -0,0 +1,51 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["eventsCount", "rulesCount", "networkRangesCount", "systemHealth", "recentEvents", "topBlockedIps"]
static values = {
period: String,
refreshInterval: { type: Number, default: 30000 } // 30 seconds
}
connect() {
this.startRefreshing()
}
disconnect() {
this.stopRefreshing()
}
startRefreshing() {
this.refreshTimer = setInterval(() => {
this.refreshDashboard()
}, this.refreshIntervalValue)
}
stopRefreshing() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
}
}
async refreshDashboard() {
try {
const response = await fetch(`/analytics?period=${this.periodValue}`, {
headers: {
"Accept": "text/vnd.turbo-stream.html"
}
})
if (response.ok) {
const html = await response.text()
Turbo.renderStreamMessage(html)
}
} catch (error) {
console.error("Failed to refresh dashboard:", error)
}
}
periodChanged(event) {
this.periodValue = event.currentTarget.dataset.period
this.refreshDashboard()
}
}

View File

@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
connect() {
// Add click outside listener to close dropdown
this.boundHide = this.hide.bind(this)
document.addEventListener("click", this.boundHide)
}
disconnect() {
document.removeEventListener("click", this.boundHide)
}
toggle(event) {
event.preventDefault()
event.stopPropagation()
this.menuTarget.classList.toggle("hidden")
}
hide(event) {
// Don't hide if clicking inside the dropdown
if (this.element.contains(event.target)) return
this.menuTarget.classList.add("hidden")
}
}

View File

@@ -0,0 +1,17 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu", "open", "close"]
toggle(event) {
event.preventDefault()
const menu = this.menuTarget
const openIcon = this.openTarget
const closeIcon = this.closeTarget
menu.classList.toggle("hidden")
openIcon.classList.toggle("hidden")
closeIcon.classList.toggle("hidden")
}
}

View File

@@ -15,7 +15,6 @@ class EventNormalizationJob < ApplicationJob
events = Event.where(request_host_id: nil) events = Event.where(request_host_id: nil)
.limit(batch_size) .limit(batch_size)
.offset(offset) .offset(offset)
.includes(:project)
break if events.empty? break if events.empty?

View File

@@ -3,21 +3,20 @@
class GenerateWafRulesJob < ApplicationJob class GenerateWafRulesJob < ApplicationJob
queue_as :waf_rules queue_as :waf_rules
def perform(project_id:, event_id:) def perform(event_id:)
project = Project.find(project_id)
event = Event.find(event_id) event = Event.find(event_id)
# Only analyze blocked events for rule generation # Only analyze blocked events for rule generation
return unless event.blocked? return unless event.blocked?
# Generate different types of rules based on patterns # Generate different types of rules based on patterns
generate_ip_rules(project, event) generate_ip_rules(event)
generate_path_rules(project, event) generate_path_rules(event)
generate_user_agent_rules(project, event) generate_user_agent_rules(event)
generate_parameter_rules(project, event) generate_parameter_rules(event)
# Notify project of new rules # Broadcast rule updates globally
project.broadcast_rules_refresh ActionCable.server.broadcast("rules", { type: "refresh" })
rescue => e rescue => e
Rails.logger.error "Error generating WAF rules: #{e.message}" Rails.logger.error "Error generating WAF rules: #{e.message}"
@@ -26,30 +25,23 @@ class GenerateWafRulesJob < ApplicationJob
private private
def generate_ip_rules(project, event) def generate_ip_rules(event)
return unless event.ip_address.present? return unless event.ip_address.present?
# Check if this IP has multiple violations # Check if this IP has multiple violations
violation_count = project.events violation_count = Event
.by_ip(event.ip_address) .by_ip(event.ip_address)
.blocked .blocked
.where(timestamp: 24.hours.ago..Time.current) .where(timestamp: 24.hours.ago..Time.current)
.count .count
# Auto-block IPs with 10+ violations in 24 hours # Log high-violation IPs - no automatic blocking without projects
if violation_count >= 10 && !project.blocked_ips.include?(event.ip_address) if violation_count >= 10
project.add_ip_rule( Rails.logger.info "IP with high violation count: #{event.ip_address} (#{violation_count} violations in 24 hours)"
event.ip_address,
'block',
expires_at: 7.days.from_now,
reason: "Auto-generated: #{violation_count} violations in 24 hours"
)
Rails.logger.info "Auto-blocked IP #{event.ip_address} for project #{project.slug}"
end end
end end
def generate_path_rules(project, event) def generate_path_rules(event)
return unless event.request_path.present? return unless event.request_path.present?
# Look for repeated attack patterns on specific paths # Look for repeated attack patterns on specific paths
@@ -65,7 +57,7 @@ class GenerateWafRulesJob < ApplicationJob
end end
end end
def generate_user_agent_rules(project, event) def generate_user_agent_rules(event)
return unless event.user_agent.present? return unless event.user_agent.present?
# Look for malicious user agents # Look for malicious user agents
@@ -81,7 +73,7 @@ class GenerateWafRulesJob < ApplicationJob
end end
end end
def generate_parameter_rules(project, event) def generate_parameter_rules(event)
params = event.query_params params = event.query_params
return unless params.present? return unless params.present?

View File

@@ -3,17 +3,16 @@
class ProcessWafAnalyticsJob < ApplicationJob class ProcessWafAnalyticsJob < ApplicationJob
queue_as :waf_analytics queue_as :waf_analytics
def perform(project_id:, event_id:) def perform(event_id:)
project = Project.find(project_id)
event = Event.find(event_id) event = Event.find(event_id)
# Analyze event patterns # Analyze event patterns
analyze_traffic_patterns(project, event) analyze_traffic_patterns(event)
analyze_geographic_distribution(project, event) analyze_geographic_distribution(event)
analyze_attack_vectors(project, event) analyze_attack_vectors(event)
# Update project analytics cache # Update global analytics cache
update_project_analytics(project) update_analytics_cache
rescue => e rescue => e
Rails.logger.error "Error processing WAF analytics: #{e.message}" Rails.logger.error "Error processing WAF analytics: #{e.message}"
@@ -22,14 +21,15 @@ class ProcessWafAnalyticsJob < ApplicationJob
private private
def analyze_traffic_patterns(project, event) def analyze_traffic_patterns(event)
# Look for unusual traffic spikes # Look for unusual traffic spikes
recent_events = project.events.where(timestamp: 5.minutes.ago..Time.current) recent_events = Event.where(timestamp: 5.minutes.ago..Time.current)
if recent_events.count > project.rate_limit_threshold * 5 # 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 # High traffic detected - create an issue
Issue.create!( Issue.create!(
project: project,
title: "High Traffic Spike Detected", title: "High Traffic Spike Detected",
description: "Detected #{recent_events.count} requests in the last 5 minutes", description: "Detected #{recent_events.count} requests in the last 5 minutes",
severity: "medium", severity: "medium",
@@ -37,56 +37,51 @@ class ProcessWafAnalyticsJob < ApplicationJob
metadata: { metadata: {
event_count: recent_events.count, event_count: recent_events.count,
time_window: "5 minutes", time_window: "5 minutes",
threshold: project.rate_limit_threshold * 5 threshold: threshold * 5
} }
) )
end end
end end
def analyze_geographic_distribution(project, event) def analyze_geographic_distribution(event)
return unless event.country_code.present? return unless event.country_code.present?
# Check if this country is unusual for this project # Check if this country is unusual globally
country_events = project.events country_events = Event
.where(country_code: event.country_code) .where(country_code: event.country_code)
.where(timestamp: 1.hour.ago..Time.current) .where(timestamp: 1.hour.ago..Time.current)
# If this is the first event from this country or unusual spike # If this is the first event from this country or unusual spike
if country_events.count == 1 || country_events.count > 100 if country_events.count == 1 || country_events.count > 100
Rails.logger.info "Unusual geographic activity from #{event.country_code} for project #{project.slug}" Rails.logger.info "Unusual geographic activity from #{event.country_code}"
end end
end end
def analyze_attack_vectors(project, event) def analyze_attack_vectors(event)
return unless event.blocked? return unless event.blocked?
# Analyze common attack patterns # Analyze common attack patterns
analyze_ip_reputation(project, event) analyze_ip_reputation(event)
analyze_user_agent_patterns(project, event) analyze_user_agent_patterns(event)
analyze_path_attacks(project, event) analyze_path_attacks(event)
end end
def analyze_ip_reputation(project, event) def analyze_ip_reputation(event)
return unless event.ip_address.present? return unless event.ip_address.present?
# Count recent blocks from this IP # Count recent blocks from this IP
recent_blocks = project.events recent_blocks = Event
.by_ip(event.ip_address) .by_ip(event.ip_address)
.blocked .blocked
.where(timestamp: 1.hour.ago..Time.current) .where(timestamp: 1.hour.ago..Time.current)
if recent_blocks.count >= 5 if recent_blocks.count >= 5
# Suggest automatic IP block # Log IP reputation issue - no automatic IP blocking without projects
project.add_ip_rule( Rails.logger.warn "IP with poor reputation detected: #{event.ip_address} (#{recent_blocks.count} blocks in 1 hour)"
event.ip_address,
'block',
expires_at: 24.hours.from_now,
reason: "Automated block: #{recent_blocks.count} violations in 1 hour"
)
end end
end end
def analyze_user_agent_patterns(project, event) def analyze_user_agent_patterns(event)
return unless event.user_agent.present? return unless event.user_agent.present?
# Look for common bot/user agent patterns # Look for common bot/user agent patterns
@@ -101,7 +96,7 @@ class ProcessWafAnalyticsJob < ApplicationJob
end end
end end
def analyze_path_attacks(project, event) def analyze_path_attacks(event)
return unless event.request_path.present? return unless event.request_path.present?
# Look for common attack paths # Look for common attack paths
@@ -119,8 +114,8 @@ class ProcessWafAnalyticsJob < ApplicationJob
end end
end end
def update_project_analytics(project) def update_analytics_cache
# Update cached analytics for faster dashboard loading # Update cached analytics for faster dashboard loading
Rails.cache.delete("project_#{project.id}_analytics") Rails.cache.delete("global_analytics")
end end
end end

View File

@@ -3,9 +3,7 @@
class ProcessWafEventJob < ApplicationJob class ProcessWafEventJob < ApplicationJob
queue_as :waf_events queue_as :waf_events
def perform(project_id:, event_data:, headers:) def perform(event_data:, headers:)
project = Project.find(project_id)
# Handle both single event and events array # Handle both single event and events array
events_to_process = [] events_to_process = []
@@ -26,7 +24,7 @@ class ProcessWafEventJob < ApplicationJob
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
event = Event.create_from_waf_payload!(event_id, single_event_data, project) event = Event.create_from_waf_payload!(event_id, single_event_data)
# Enrich with geo-location data if missing # Enrich with geo-location data if missing
if event.ip_address.present? && event.country_code.blank? if event.ip_address.present? && event.country_code.blank?
@@ -38,12 +36,12 @@ class ProcessWafEventJob < ApplicationJob
end end
# Trigger analytics processing # Trigger analytics processing
ProcessWafAnalyticsJob.perform_later(project_id: project_id, event_id: event.id) ProcessWafAnalyticsJob.perform_later(event_id: event.id)
# Check for automatic rule generation opportunities # Check for automatic rule generation opportunities
GenerateWafRulesJob.perform_later(project_id: project_id, event_id: event.id) GenerateWafRulesJob.perform_later(event_id: event.id)
Rails.logger.info "Processed WAF event #{event_id} for project #{project.slug}" 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(", ")
@@ -54,8 +52,8 @@ class ProcessWafEventJob < ApplicationJob
end end
# Broadcast real-time updates once per batch # Broadcast real-time updates once per batch
project.broadcast_events_refresh ActionCable.server.broadcast("events", { type: "refresh" })
Rails.logger.info "Processed #{events_to_process.count} WAF events for project #{project.slug}" Rails.logger.info "Processed #{events_to_process.count} WAF events"
end end
end end

18
app/models/dsn.rb Normal file
View File

@@ -0,0 +1,18 @@
class Dsn < ApplicationRecord
validates :key, presence: true, uniqueness: true
validates :name, presence: true
before_validation :generate_key, on: :create
scope :enabled, -> { where(enabled: true) }
def self.authenticate(key)
enabled.find_by(key: key)
end
private
def generate_key
self.key ||= SecureRandom.hex(32)
end
end

View File

@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Event < ApplicationRecord class Event < ApplicationRecord
belongs_to :project
# Normalized association for hosts (most valuable compression) # Normalized association for hosts (most valuable compression)
belongs_to :request_host, optional: true belongs_to :request_host, optional: true
@@ -87,13 +85,12 @@ class Event < ApplicationRecord
# Normalize event fields after extraction # Normalize event fields after extraction
after_validation :normalize_event_fields, if: :should_normalize? after_validation :normalize_event_fields, if: :should_normalize?
def self.create_from_waf_payload!(event_id, payload, project) def self.create_from_waf_payload!(event_id, payload)
# Normalize headers in payload during import phase # Normalize headers in payload during import phase
normalized_payload = normalize_payload_headers(payload) normalized_payload = normalize_payload_headers(payload)
# Create the WAF request event # Create the WAF request event
create!( create!(
project: project,
event_id: event_id, event_id: event_id,
timestamp: parse_timestamp(normalized_payload["timestamp"]), timestamp: parse_timestamp(normalized_payload["timestamp"]),
payload: normalized_payload, payload: normalized_payload,

View File

@@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Issue < ApplicationRecord class Issue < ApplicationRecord
belongs_to :project
has_many :events, dependent: :nullify has_many :events, dependent: :nullify
enum :status, { open: 0, resolved: 1, ignored: 2 } enum :status, { open: 0, resolved: 1, ignored: 2 }
@@ -17,18 +16,18 @@ class Issue < ApplicationRecord
# Real-time updates # Real-time updates
after_create_commit do after_create_commit do
broadcast_refresh_to(project) broadcast_refresh
end end
after_update_commit do after_update_commit do
broadcast_refresh # Refreshes the issue show page broadcast_refresh # Refreshes the issue show page
broadcast_refresh_to(project, "issues") # Refreshes the project's issues index broadcast_refresh_to("issues") # Refreshes the issues index
end end
def self.group_event(event_payload, project) def self.group_event(event_payload)
fingerprint = generate_fingerprint(event_payload) fingerprint = generate_fingerprint(event_payload)
find_or_create_by(project: project, fingerprint: fingerprint) do |issue| find_or_create_by(fingerprint: fingerprint) do |issue|
issue.title = extract_title(event_payload) issue.title = extract_title(event_payload)
issue.exception_type = extract_exception_type(event_payload) issue.exception_type = extract_exception_type(event_payload)
issue.first_seen = Time.current issue.first_seen = Time.current

View File

@@ -29,6 +29,8 @@ class NetworkRange < ApplicationRecord
scope :vpn, -> { where(is_vpn: true) } scope :vpn, -> { where(is_vpn: true) }
scope :user_created, -> { where(source: 'user_created') } scope :user_created, -> { where(source: 'user_created') }
scope :api_imported, -> { where(source: 'api_imported') } scope :api_imported, -> { where(source: 'api_imported') }
scope :with_events, -> { where("events_count > 0") }
scope :most_active, -> { order(events_count: :desc) }
# Callbacks # Callbacks
before_validation :set_default_source before_validation :set_default_source
@@ -237,9 +239,10 @@ class NetworkRange < ApplicationRecord
cidr.to_s.gsub('/', '_') cidr.to_s.gsub('/', '_')
end end
# Analytics methods # Analytics methods - events_count is now a counter cache column maintained by database triggers
# This is much more performant than the previous implementation that did complex network queries
def events_count def events_count
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]).count self[:events_count] || 0
end end
def recent_events(limit: 100) def recent_events(limit: 100)

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class AnalyticsPolicy < ApplicationPolicy
def index?
# Everyone can view analytics (including unauthenticated users for monitoring)
true
end
end

View File

@@ -0,0 +1,49 @@
# frozen_string_literal: true
class DsnPolicy < ApplicationPolicy
def index?
current_user.present? && current_user.admin?
end
def show?
current_user.present? && current_user.admin?
end
def create?
current_user.present? && current_user.admin?
end
def new?
create?
end
def update?
current_user.present? && current_user.admin?
end
def edit?
update?
end
def destroy?
current_user.present? && current_user.admin?
end
def disable?
current_user.present? && current_user.admin?
end
def enable?
current_user.present? && current_user.admin?
end
class Scope < Scope
def resolve
if user&.admin?
scope.all
else
scope.none
end
end
end
end

View File

@@ -3,24 +3,24 @@
class DsnAuthenticationService class DsnAuthenticationService
class AuthenticationError < StandardError; end class AuthenticationError < StandardError; end
def self.authenticate(request, project_id) def self.authenticate(request)
# Try multiple authentication methods in order of preference # Try multiple authentication methods in order of preference
# Method 1: Query parameter authentication # Method 1: Query parameter authentication
public_key = extract_key_from_query_params(request) dsn_key = extract_key_from_query_params(request)
return find_project(public_key, project_id) if public_key return find_dsn(dsn_key) if dsn_key
# Method 2: X-Baffle-Auth header (similar to X-Sentry-Auth) # Method 2: X-Baffle-Auth header (similar to X-Sentry-Auth)
public_key = extract_key_from_baffle_auth_header(request) dsn_key = extract_key_from_baffle_auth_header(request)
return find_project(public_key, project_id) if public_key return find_dsn(dsn_key) if dsn_key
# Method 3: Authorization Bearer token # Method 3: Authorization Bearer token
public_key = extract_key_from_authorization_header(request) dsn_key = extract_key_from_authorization_header(request)
return find_project(public_key, project_id) if public_key return find_dsn(dsn_key) if dsn_key
# Method 4: Basic auth (username is the public_key) # Method 4: Basic auth (username is the dsn_key)
public_key = extract_key_from_basic_auth(request) dsn_key = extract_key_from_basic_auth(request)
return find_project(public_key, project_id) if public_key return find_dsn(dsn_key) if dsn_key
raise AuthenticationError, "No valid authentication method found" raise AuthenticationError, "No valid authentication method found"
end end
@@ -36,8 +36,8 @@ class DsnAuthenticationService
auth_header = request.headers['X-Baffle-Auth'] || request.headers['X-Sentry-Auth'] auth_header = request.headers['X-Baffle-Auth'] || request.headers['X-Sentry-Auth']
return nil unless auth_header return nil unless auth_header
# Parse: Baffle baffle_key=public_key, baffle_version=1 # Parse: Baffle baffle_key=dsn_key, baffle_version=1
# Or: Sentry sentry_key=public_key, sentry_version=7 # Or: Sentry sentry_key=dsn_key, sentry_version=7
match = auth_header.match(/(?:baffle_key|sentry_key)=([^,\s]+)/) match = auth_header.match(/(?:baffle_key|sentry_key)=([^,\s]+)/)
match&.[](1) match&.[](1)
end end
@@ -46,7 +46,7 @@ class DsnAuthenticationService
authorization_header = request.headers['Authorization'] authorization_header = request.headers['Authorization']
return nil unless authorization_header return nil unless authorization_header
# Parse: Bearer public_key # Parse: Bearer dsn_key
if authorization_header.start_with?('Bearer ') if authorization_header.start_with?('Bearer ')
authorization_header[7..-1].strip authorization_header[7..-1].strip
end end
@@ -62,20 +62,16 @@ class DsnAuthenticationService
username username
end end
def self.find_project(public_key, project_id) def self.find_dsn(dsn_key)
return nil unless public_key.present? && project_id.present? return nil unless dsn_key.present?
# Find project by public_key first # Find DSN by key
project = Project.find_by(public_key: public_key) dsn = Dsn.authenticate(dsn_key)
raise AuthenticationError, "Invalid public_key" unless project raise AuthenticationError, "Invalid DSN key" unless dsn
# Verify project_id matches (supports both slug and ID) # Ensure DSN is enabled
project_matches = Project.find_by(slug: project_id) || Project.find_by(id: project_id) raise AuthenticationError, "DSN is disabled" unless dsn.enabled?
raise AuthenticationError, "Invalid project_id" unless project_matches == project
# Ensure project is enabled dsn
raise AuthenticationError, "Project is disabled" unless project.enabled?
project
end end
end end

View File

@@ -0,0 +1,343 @@
<% content_for :title, "Analytics Dashboard - Baffle Hub" %>
<div class="space-y-6" data-controller="dashboard" data-dashboard-period-value="<%= @time_period %>">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
<p class="mt-2 text-gray-600">Overview of WAF events, rules, and network activity</p>
</div>
<div class="flex items-center space-x-4">
<!-- Auto-refresh indicator -->
<div class="flex items-center text-sm text-gray-500">
<svg class="animate-spin h-4 w-4 mr-2 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Auto-refreshing
</div>
<!-- Time Period Selector -->
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">Time Period:</span>
<div class="flex bg-white rounded-md shadow-sm border border-gray-300">
<%= link_to "1H", analytics_path(period: :hour),
class: time_period_class(:hour),
data: { action: "click->dashboard#periodChanged", period: "hour" } %>
<%= link_to "24H", analytics_path(period: :day),
class: time_period_class(:day),
data: { action: "click->dashboard#periodChanged", period: "day" } %>
<%= link_to "1W", analytics_path(period: :week),
class: time_period_class(:week),
data: { action: "click->dashboard#periodChanged", period: "week" } %>
<%= link_to "1M", analytics_path(period: :month),
class: time_period_class(:month),
data: { action: "click->dashboard#periodChanged", period: "month" } %>
</div>
</div>
</div>
</div>
<!-- Key Statistics Cards -->
<div class="space-y-6">
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total Events -->
<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 bg-blue-500 rounded-md flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.92 6-4.72 7.28l-.28.12V22l-5-5 5-5v2.4c2.21-.47 3.88-2.35 3.88-4.65 0-2.76-2.24-5-5-5-1.3 0-2.47.5-3.36 1.31L9 6.36C10.11 5.26 11.49 4.56 13 4.56V2.05c0-.45.54-.67.85-.35l8.78 8.78c.2.2.2.51 0 .71l-8.78 8.78c-.31.31-.85.1-.85-.35v-2.02c-5.05-.5-9-4.76-9-9.93 0-4.08 2.73-7.54 6.58-8.67L13 2.05z"/>
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Events</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_events) %></dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-green-600 font-medium">Last <%= @time_period.to_s.humanize %></span>
</div>
</div>
</div>
<!-- Active Rules -->
<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 bg-green-500 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 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Active Rules</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_rules) %></dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-green-600 font-medium">Enabled</span>
<% if @system_health[:disabled_rules] > 0 %>
<span class="text-gray-500"> · <%= @system_health[:disabled_rules] %> disabled</span>
<% end %>
</div>
</div>
</div>
<!-- Network Ranges with Events -->
<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 bg-purple-500 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">Active Network Ranges</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@network_ranges_with_events) %></dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-purple-600 font-medium">of <%= number_with_delimiter(@total_network_ranges) %> total</span>
</div>
</div>
</div>
<!-- System Health -->
<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 bg-orange-500 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 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">System Health</dt>
<dd class="text-lg font-medium text-gray-900">Normal</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-green-600 font-medium">All systems operational</span>
</div>
</div>
</div>
</div>
<!-- Charts and Detailed Analytics -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Events Timeline Chart -->
<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">Events Timeline (Last 24 Hours)</h3>
</div>
<div class="p-6">
<div class="space-y-4">
<% @chart_data[:timeline].each do |data| %>
<div class="flex items-center">
<div class="w-16 text-sm text-gray-500"><%= data[:time] %></div>
<div class="flex-1 mx-4">
<div class="bg-gray-200 rounded-full h-4">
<div class="bg-blue-600 h-4 rounded-full"
style="width: <%= [((data[:total].to_f / [@chart_data[:timeline].map { |d| d[:total] }.max, 1].max) * 100), 5].max %>%">
</div>
</div>
</div>
<div class="w-12 text-sm text-gray-900 text-right"><%= data[:total] %></div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Event Actions Breakdown -->
<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">Event Actions</h3>
</div>
<div class="p-6">
<% if @chart_data[:actions].any? %>
<div class="space-y-4">
<% @chart_data[:actions].each do |action| %>
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-3
<%= case action[:action]
when 'Allow' then 'bg-green-500'
when 'Deny', 'Block' then 'bg-red-500'
when 'Challenge' then 'bg-yellow-500'
else 'bg-gray-500'
end %>">
</div>
<span class="text-sm font-medium text-gray-900"><%= action[:action] %></span>
</div>
<div class="flex items-center">
<span class="text-sm text-gray-600 mr-2"><%= number_with_delimiter(action[:count]) %></span>
<span class="text-sm text-gray-500">(<%= action[:percentage] %>%)</span>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="text-gray-500 text-center py-8">No events in the selected time period</p>
<% end %>
</div>
</div>
</div>
<!-- Secondary Information Rows -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Top Countries -->
<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">Top Countries</h3>
</div>
<div class="p-6">
<% if @top_countries.any? %>
<div class="space-y-3">
<% @top_countries.first(5).each do |country, count| %>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-900"><%= country %></span>
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(count) %></span>
</div>
<% end %>
</div>
<% else %>
<p class="text-gray-500 text-center py-4">No country data available</p>
<% end %>
</div>
</div>
<!-- Network Intelligence -->
<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">Network Intelligence</h3>
</div>
<div class="p-6">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-900">🏢 Datacenter</span>
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(@network_intelligence[:datacenter_ranges]) %></span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-900">🔒 VPN</span>
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(@network_intelligence[:vpn_ranges]) %></span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-900">🛡️ Proxy</span>
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(@network_intelligence[:proxy_ranges]) %></span>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div id="recent-activity" 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">Recent Activity</h3>
</div>
<div class="p-6">
<div class="space-y-3">
<% @recent_events.first(3).each do |event| %>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center">
<div class="w-2 h-2 rounded-full mr-2
<%= event.waf_action == 'allow' ? 'bg-green-500' : 'bg-red-500' %>"></div>
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
</div>
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
</div>
<% end %>
</div>
</div>
</div>
</div>
<!-- Top Blocked IPs -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Top Blocked IPs</h3>
<%= link_to "View All Events", events_path, class: "text-sm text-blue-600 hover:text-blue-800" %>
</div>
</div>
<div class="p-6">
<% if @top_blocked_ips.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<% @top_blocked_ips.each do |ip, count| %>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-lg font-mono font-medium text-gray-900"><%= ip %></div>
<div class="text-sm text-red-600"><%= number_with_delimiter(count) %> blocks</div>
</div>
<% end %>
</div>
<% else %>
<p class="text-gray-500 text-center py-8">No blocked events in the selected time period</p>
<% end %>
</div>
</div>
<!-- Quick Actions -->
<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">Quick Actions</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<%= link_to new_rule_path, class: "flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" do %>
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
Create Rule
<% end %>
<%= link_to new_network_range_path, class: "flex items-center justify-center px-4 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors" do %>
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
Add Network Range
<% end %>
<%= link_to events_path, class: "flex items-center justify-center px-4 py-3 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors" do %>
<svg class="w-5 h-5 mr-2" 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 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
View Events
<% end %>
<%= link_to rules_path, class: "flex items-center justify-center px-4 py-3 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors" do %>
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
</svg>
Manage Rules
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,132 @@
<%= turbo_stream.replace "dashboard-stats" do %>
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total Events -->
<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 bg-blue-500 rounded-md flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.92 6-4.72 7.28l-.28.12V22l-5-5 5-5v2.4c2.21-.47 3.88-2.35 3.88-4.65 0-2.76-2.24-5-5-5-1.3 0-2.47.5-3.36 1.31L9 6.36C10.11 5.26 11.49 4.56 13 4.56V2.05c0-.45.54-.67.85-.35l8.78 8.78c.2.2.2.51 0 .71l-8.78 8.78c-.31.31-.85.1-.85-.35v-2.02c-5.05-.5-9-4.76-9-9.93 0-4.08 2.73-7.54 6.58-8.67L13 2.05z"/>
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Events</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_events) %></dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-green-600 font-medium">Last <%= @time_period.to_s.humanize %></span>
</div>
</div>
</div>
<!-- Active Rules -->
<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 bg-green-500 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 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Active Rules</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_rules) %></dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-green-600 font-medium">Enabled</span>
<% if @system_health[:disabled_rules] > 0 %>
<span class="text-gray-500"> · <%= @system_health[:disabled_rules] %> disabled</span>
<% end %>
</div>
</div>
</div>
<!-- Network Ranges with Events -->
<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 bg-purple-500 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">Active Network Ranges</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@network_ranges_with_events) %></dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-purple-600 font-medium">of <%= number_with_delimiter(@total_network_ranges) %> total</span>
</div>
</div>
</div>
<!-- System Health -->
<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 bg-orange-500 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 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">System Health</dt>
<dd class="text-lg font-medium text-gray-900">Normal</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="text-green-600 font-medium">All systems operational</span>
</div>
</div>
</div>
</div>
<% end %>
<%= turbo_stream.replace "recent-activity" do %>
<div id="recent-activity" 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">Recent Activity</h3>
</div>
<div class="p-6">
<div class="space-y-3">
<% @recent_events.first(3).each do |event| %>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center">
<div class="w-2 h-2 rounded-full mr-2
<%= event.waf_action == 'allow' ? 'bg-green-500' : 'bg-red-500' %>"></div>
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
</div>
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
</div>
<% end %>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,95 @@
<% content_for :title, "Edit DSN - #{@dsn.name}" %>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div class="md:flex md:items-center md:justify-between mb-8">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
Edit DSN
</h2>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4">
<%= link_to "Back to DSN", @dsn, 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 class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= form_with(model: @dsn, local: true, class: "space-y-6") do |form| %>
<% if @dsn.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
There were <%= pluralize(@dsn.errors.count, "error") %> with your submission:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc space-y-1 pl-5">
<% @dsn.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div class="sm:col-span-6">
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1">
<%= form.text_field :name, class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "e.g., Production DSN, Development DSN" %>
</div>
<p class="mt-2 text-sm text-gray-500">
A descriptive name to help you identify this DSN key.
</p>
</div>
<div class="sm:col-span-6">
<div class="flex items-start">
<div class="flex items-center h-5">
<%= form.check_box :enabled, class: "focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" %>
</div>
<div class="ml-3 text-sm">
<%= form.label :enabled, class: "font-medium text-gray-700" %>
<p class="text-gray-500">Enable this DSN for agent authentication</p>
</div>
</div>
</div>
</div>
<!-- DSN Key Display (Read-only) -->
<div class="bg-gray-50 border border-gray-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-gray-800">
DSN Key
</h3>
<div class="mt-1 text-sm text-gray-600 font-mono bg-white px-3 py-2 rounded border">
<%= @dsn.key %>
</div>
<p class="mt-1 text-xs text-gray-500">DSN keys cannot be changed after creation.</p>
</div>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<%= link_to "Cancel", @dsn, class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<%= form.submit "Update DSN", class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm 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" %>
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,162 @@
<% content_for :title, "DSNs" %>
<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">DSN Management</h1>
<p class="mt-2 text-gray-600">Manage DSN keys for agent authentication</p>
</div>
<% if policy(Dsn).create? %>
<div class="flex space-x-3">
<%= link_to "New DSN", new_dsn_path, class: "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" %>
</div>
<% end %>
</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 -->
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<% if @dsns.any? %>
<ul role="list" class="divide-y divide-gray-200">
<% @dsns.each do |dsn| %>
<li>
<div class="px-4 py-4 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<% if dsn.enabled? %>
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
<% else %>
<div class="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
<% end %>
</div>
<div class="ml-4">
<div class="flex items-center">
<p class="text-sm font-medium text-gray-900"><%= dsn.name %></p>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= dsn.enabled? ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' %>">
<%= dsn.enabled? ? 'Enabled' : 'Disabled' %>
</span>
</div>
<p class="text-sm text-gray-500 font-mono">
Key: <%= dsn.key[0..15] + "..." %>
</p>
<p class="text-xs text-gray-400">
Created: <%= dsn.created_at.strftime('%Y-%m-%d %H:%M') %>
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<%= link_to "View", dsn, class: "text-blue-600 hover:text-blue-900 text-sm font-medium" %>
<% if policy(dsn).edit? %>
<%= link_to "Edit", edit_dsn_path(dsn), class: "text-indigo-600 hover:text-indigo-900 text-sm font-medium" %>
<% end %>
<% if policy(dsn).disable? && dsn.enabled? %>
<%= link_to "Disable", disable_dsn_path(dsn), method: :post,
data: { confirm: "Are you sure you want to disable this DSN?" },
class: "text-red-600 hover:text-red-900 text-sm font-medium" %>
<% elsif policy(dsn).enable? && !dsn.enabled? %>
<%= link_to "Enable", enable_dsn_path(dsn), method: :post,
class: "text-green-600 hover:text-green-900 text-sm font-medium" %>
<% end %>
</div>
</div>
</li>
<% end %>
</ul>
<% else %>
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No DSNs</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new DSN.</p>
<% if policy(Dsn).create? %>
<div class="mt-6">
<%= link_to "New DSN", new_dsn_path, class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Show a brief confirmation
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<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="M5 13l4 4L19 7"></path></svg> Copied!';
button.classList.add('text-green-600');
button.classList.remove('text-blue-600');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('text-green-600');
button.classList.add('text-blue-600');
}, 2000);
});
}
</script>

View File

@@ -0,0 +1,93 @@
<% content_for :title, "New DSN" %>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div class="md:flex md:items-center md:justify-between mb-8">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
New DSN
</h2>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4">
<%= link_to "Back to DSNs", dsns_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 class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= form_with(model: @dsn, local: true, class: "space-y-6") do |form| %>
<% if @dsn.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
There were <%= pluralize(@dsn.errors.count, "error") %> with your submission:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc space-y-1 pl-5">
<% @dsn.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div class="sm:col-span-6">
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1">
<%= form.text_field :name, class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md", placeholder: "e.g., Production DSN, Development DSN" %>
</div>
<p class="mt-2 text-sm text-gray-500">
A descriptive name to help you identify this DSN key.
</p>
</div>
<div class="sm:col-span-6">
<div class="flex items-start">
<div class="flex items-center h-5">
<%= form.check_box :enabled, class: "focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" %>
</div>
<div class="ml-3 text-sm">
<%= form.label :enabled, class: "font-medium text-gray-700" %>
<p class="text-gray-500">Enable this DSN for agent authentication</p>
</div>
</div>
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">
DSN Key Information
</h3>
<div class="mt-2 text-sm text-blue-700">
<p>A unique DSN key will be automatically generated when you create this DSN. This key will be used by your baffle-agents to authenticate with the hub.</p>
</div>
</div>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<%= link_to "Cancel", dsns_path, class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<%= form.submit "Create DSN", class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm 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" %>
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,138 @@
<% content_for :title, "DSN - #{@dsn.name}" %>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div class="md:flex md:items-center md:justify-between mb-8">
<div class="flex-1 min-w-0">
<div class="flex items-center">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
<%= @dsn.name %>
</h2>
<span class="ml-3 inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium <%= @dsn.enabled? ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' %>">
<%= @dsn.enabled? ? 'Enabled' : 'Disabled' %>
</span>
</div>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4 space-x-3">
<%= link_to "Back to DSNs", dsns_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" %>
<% 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" %>
<% end %>
</div>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">DSN Information</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
DSN key details and usage information.
</p>
</div>
<div class="border-t border-gray-200">
<dl>
<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">Name</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @dsn.name %></dd>
</div>
<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">DSN Key</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"><%= @dsn.key %></code>
<button onclick="copyToClipboard('<%= @dsn.key %>')" 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>
</dd>
</div>
<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">Status</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= @dsn.enabled? ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' %>">
<%= @dsn.enabled? ? 'Enabled' : 'Disabled' %>
</span>
</dd>
</div>
<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">Created</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<%= @dsn.created_at.strftime('%B %d, %Y at %I:%M %p') %>
</dd>
</div>
<% if @dsn.updated_at != @dsn.created_at %>
<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">Last Updated</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<%= @dsn.updated_at.strftime('%B %d, %Y at %I:%M %p') %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<!-- Actions -->
<% if policy(@dsn).disable? || policy(@dsn).enable? %>
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">Actions</h3>
<div class="mt-5 space-x-3">
<% if @dsn.enabled? && policy(@dsn).disable? %>
<%= link_to "Disable DSN", disable_dsn_path(@dsn), method: :post,
data: { confirm: "Are you sure you want to disable this DSN? Agents will no longer be able to authenticate with this key." },
class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% elsif !@dsn.enabled? && policy(@dsn).enable? %>
<%= link_to "Enable DSN", enable_dsn_path(@dsn), method: :post,
class: "inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %>
<% end %>
</div>
</div>
</div>
<% end %>
<!-- Usage Instructions -->
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">Usage Instructions</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
How to use this DSN key with your baffle-agents.
</p>
</div>
<div class="border-t border-gray-200 px-4 py-5 sm:px-6">
<div class="prose max-w-none">
<h4>HTTP Header Authentication</h4>
<p>Include the DSN key in the <code>Authorization</code> header:</p>
<pre class="bg-gray-100 p-3 rounded text-sm"><code>Authorization: Bearer <%= @dsn.key %></code></pre>
<h4 class="mt-4">Query Parameter Authentication</h4>
<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>
<h4 class="mt-4">X-Baffle-Auth Header</h4>
<p>Use the custom Baffle authentication header:</p>
<pre class="bg-gray-100 p-3 rounded text-sm"><code>X-Baffle-Auth: Baffle baffle_key=<%= @dsn.key %>, baffle_version=1</code></pre>
</div>
</div>
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Show a brief confirmation
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<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="M5 13l4 4L19 7"></path></svg> Copied!';
button.classList.add('text-green-600');
button.classList.remove('text-blue-600');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('text-green-600');
button.classList.add('text-blue-600');
}, 2000);
});
}
</script>

View File

@@ -1,112 +1,157 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <% content_for :title, "Events - Baffle Hub" %>
<h1><%= @project.name %> - Events</h1>
<div class="space-y-6">
<!-- Header -->
<div> <div>
<%= link_to "← Back to Project", @project, class: "btn btn-secondary" %> <h1 class="text-3xl font-bold text-gray-900">Events</h1>
<%= link_to "Analytics", analytics_project_path(@project), class: "btn btn-info" %> <p class="mt-2 text-gray-600">WAF event log and analysis</p>
</div> </div>
</div>
<!-- Filters --> <!-- Filters -->
<div class="card mb-4"> <div class="bg-white shadow rounded-lg">
<div class="card-header"> <div class="px-6 py-4 border-b border-gray-200">
<h5>Filters</h5> <h3 class="text-lg font-medium text-gray-900">Filters</h3>
</div>
<div class="p-6">
<%= form_with url: events_path, method: :get, local: true, class: "space-y-4" do |form| %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<%= form.label :ip, "IP Address", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :ip, value: params[:ip],
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: "Filter by IP" %>
</div>
<div>
<%= form.label :waf_action, "Action", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :waf_action,
options_for_select([['All', ''], ['Allow', 'allow'], ['Block', 'block'], ['Challenge', 'challenge']], params[:waf_action]),
{ }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
</div>
<div>
<%= form.label :country, "Country", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :country, value: params[:country],
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: "Country code (e.g. US)" %>
</div>
<div class="flex items-end space-x-2">
<%= form.submit "Apply Filters",
class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm 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" %>
<%= link_to "Clear", events_path,
class: "inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm 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" %>
</div>
</div>
<% end %>
</div>
</div> </div>
<div class="card-body">
<%= form_with url: project_events_path(@project), method: :get, local: true, class: "row g-3" do |form| %>
<div class="col-md-3">
<%= form.label :ip, "IP Address", class: "form-label" %>
<%= form.text_field :ip, value: params[:ip], class: "form-control", placeholder: "Filter by IP" %>
</div>
<div class="col-md-3">
<%= form.label :waf_action, "Action", class: "form-label" %>
<%= form.select :waf_action,
options_for_select([['All', ''], ['Allow', 'allow'], ['Block', 'block'], ['Challenge', 'challenge']], params[:waf_action]),
{}, { class: "form-select" } %>
</div>
<div class="col-md-3">
<%= form.label :country, "Country", class: "form-label" %>
<%= form.text_field :country, value: params[:country], class: "form-control", placeholder: "Country code (e.g. US)" %>
</div>
<div class="col-md-3 d-flex align-items-end">
<%= form.submit "Apply Filters", class: "btn btn-primary me-2" %>
<%= link_to "Clear", project_events_path(@project), class: "btn btn-outline-secondary" %>
</div>
<% end %>
</div>
</div>
<!-- Events Table --> <!-- Events Table -->
<div class="card"> <div class="bg-white shadow rounded-lg">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="px-6 py-4 border-b border-gray-200">
<h5>Events (<%= @events.count %>)</h5> <div class="flex items-center justify-between">
</div> <h3 class="text-lg font-medium text-gray-900">Events (<%= number_with_delimiter(@events.count) %>)</h3>
<div class="card-body"> <div class="flex items-center space-x-4">
<% if @events.any? %> <%= link_to "📊 Analytics Dashboard", analytics_path,
<div class="table-responsive"> class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
<table class="table"> <% if @pagy.pages > 1 %>
<thead> <span class="text-sm text-gray-500">
Page <%= @pagy.page %> of <%= @pagy.pages %>
</span>
<% end %>
</div>
</div>
<!-- Top Pagination -->
<% if @pagy.pages > 1 %>
<div class="mt-4">
<%= pagy_nav_tailwind(@pagy, pagy_id: 'events_top') %>
</div>
<% end %>
</div>
<div class="overflow-x-auto">
<% if @events.any? %>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr> <tr>
<th>Time</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
<th>IP Address</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP Address</th>
<th>Action</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th>Path</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Path</th>
<th>Method</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th>
<th>Status</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th>Country</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
<th>User Agent</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User Agent</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="bg-white divide-y divide-gray-200">
<% @events.each do |event| %> <% @events.each do |event| %>
<tr> <tr class="hover:bg-gray-50">
<td><%= event.timestamp.strftime("%Y-%m-%d %H:%M:%S") %></td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td><code><%= event.ip_address %></code></td> <%= event.timestamp.strftime("%Y-%m-%d %H:%M:%S") %>
<td> </td>
<span class="badge bg-<%= event.blocked? ? 'danger' : event.allowed? ? 'success' : 'warning' %>"> <td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
<%= event.ip_address %>
</td>
<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
<%= case event.waf_action
when 'allow' then 'bg-green-100 text-green-800'
when 'deny', 'block' then 'bg-red-100 text-red-800'
when 'challenge' then 'bg-yellow-100 text-yellow-800'
else 'bg-gray-100 text-gray-800'
end %>">
<%= event.waf_action %> <%= event.waf_action %>
</span> </span>
</td> </td>
<td><code><%= event.request_path %></code></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><%= event.request_method %></td> <%= event.request_path || '-' %>
<td><%= event.response_status %></td> </td>
<td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<%= event.request_method ? event.request_method.upcase : '-' %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<%= event.response_status || '-' %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<% if event.country_code.present? %> <% if event.country_code.present? %>
<span class="badge bg-light text-dark"><%= event.country_code %></span> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= event.country_code %>
</span>
<% else %> <% else %>
<span class="text-muted">-</span> <span class="text-gray-400">-</span>
<% end %> <% end %>
</td> </td>
<td class="text-truncate" style="max-width: 200px;" title="<%= event.user_agent %>"> <td class="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title="<%= event.user_agent %>">
<%= event.user_agent&.truncate(30) || '-' %> <%= event.user_agent&.truncate(50) || '-' %>
</td> </td>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>
</table> </table>
</div>
<!-- Pagination --> <!-- Bottom Pagination -->
<% if @pagy.pages > 1 %> <% if @pagy.pages > 1 %>
<div class="d-flex justify-content-center mt-4"> <%= pagy_nav_tailwind(@pagy, pagy_id: 'events_bottom') %>
<%== pagy_nav(@pagy) %> <% end %>
</div> <% else %>
<div class="text-center text-muted mt-2"> <div class="px-6 py-12 text-center">
Showing <%= @pagy.from %> to <%= @pagy.to %> of <%= @pagy.count %> events <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No events</h3>
<p class="mt-1 text-sm text-gray-500">
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
No events found matching your filters.
<% else %>
No events have been received yet.
<% end %>
</p>
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
<div class="mt-6">
<%= link_to "Clear Filters", events_path,
class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-blue-600 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
</div>
<% end %>
</div> </div>
<% end %> <% end %>
<% else %> </div>
<div class="text-center py-5">
<p class="text-muted mb-3">
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
No events found matching your filters.
<% else %>
No events have been received yet.
<% end %>
</p>
<% if params[:ip].present? || params[:waf_action].present? || params[:country].present? %>
<%= link_to "Clear Filters", project_events_path(@project), class: "btn btn-outline-primary" %>
<% end %>
</div>
<% end %>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html class="bg-gray-50">
<head> <head>
<title><%= content_for(:title) || "Baffle Hub - WAF Analytics" %></title> <title><%= content_for(:title) || "Baffle Hub - WAF Analytics" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -18,103 +18,196 @@
<link rel="icon" href="/icon.svg" type="image/svg+xml"> <link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png"> <link rel="apple-touch-icon" href="/icon.png">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<%# Includes all stylesheet files in app/assets/stylesheets %> <%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %> <%= javascript_importmap_tags %>
<style> <style>
.badge { font-size: 0.8em; } /* Custom styles for code blocks and badges */
code { code {
background-color: #f8f9fa; @apply bg-gray-100 px-2 py-1 rounded text-sm font-mono;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.875em;
} }
.navbar-brand {
font-weight: bold; /* Flash message transitions */
.flash-message {
animation: slideInDown 0.3s ease-out;
}
@keyframes slideInDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
</style> </style>
</head> </head>
<body> <body class="min-h-screen bg-gray-50">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <!-- Navigation Header -->
<div class="container"> <header class="bg-gray-900 shadow-sm border-b border-gray-800">
<%= link_to "Baffle Hub", root_path, class: "navbar-brand" %> <nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <!-- Logo and Main Navigation -->
<span class="navbar-toggler-icon"></span> <div class="flex items-center">
</button> <!-- Logo -->
<%= link_to root_path, class: "flex-shrink-0 flex items-center" do %>
<div class="collapse navbar-collapse" id="navbarNav"> <svg class="h-8 w-8 text-blue-500 mr-3" fill="currentColor" viewBox="0 0 24 24">
<ul class="navbar-nav me-auto"> <path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
<li class="nav-item"> </svg>
<%= link_to "Projects", projects_path, class: "nav-link" %> <span class="text-white text-lg font-bold">Baffle Hub</span>
</li>
<li class="nav-item">
<%= link_to "Rules", rules_path, class: "nav-link" %>
</li>
<% if user_signed_in? && current_user_admin? %>
<li class="nav-item">
<%= link_to "Users", users_path, class: "nav-link" %>
</li>
<% end %> <% end %>
</ul>
<ul class="navbar-nav"> <!-- Desktop Navigation -->
<div class="hidden md:block ml-10">
<div class="flex items-baseline space-x-4">
<%= link_to "🔴 Events", events_path,
class: nav_link_class(events_path) %>
<%= link_to "⚙️ Rules", rules_path,
class: nav_link_class(rules_path) %>
<%= link_to "🌐 Network Ranges", network_ranges_path,
class: nav_link_class(network_ranges_path) %>
<% if user_signed_in? && current_user_admin? %>
<%= link_to "🔗 DSNs", dsns_path,
class: nav_link_class(dsns_path) %>
<% end %>
</div>
</div>
</div>
<!-- Right side buttons -->
<div class="flex items-center space-x-4">
<% if user_signed_in? %> <% if user_signed_in? %>
<li class="nav-item dropdown"> <!-- User dropdown -->
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <div class="relative" data-controller="dropdown">
<button type="button"
data-action="click->dropdown#toggle click@window->dropdown#hide"
class="flex items-center text-white hover:bg-gray-800 px-3 py-2 rounded-md text-sm font-medium">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
<%= current_user.email_address %> <%= current_user.email_address %>
<span class="badge bg-secondary ms-1"><%= current_user.role %></span> <span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-600 text-white">
</a> <%= current_user.role %>
<ul class="dropdown-menu"> </span>
<li><%= link_to "Account Settings", edit_password_path, class: "dropdown-item" %></li> <svg class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 24 24">
<% if current_user_admin? %> <path d="M7 10l5 5 5-5z"/>
<li><hr class="dropdown-divider"></li> </svg>
<li><%= link_to "Manage Users", users_path, class: "dropdown-item" %></li> </button>
<% end %>
<li><hr class="dropdown-divider"></li> <!-- Dropdown menu -->
<li><%= link_to "Sign Out", session_path, data: { turbo_method: :delete }, class: "dropdown-item" %></li> <div data-dropdown-target="menu"
</ul> class="hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50">
</li> <div class="py-1">
<%= link_to edit_password_path,
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
⚙️ Account Settings
<% end %>
<% if current_user_admin? %>
<div class="border-t border-gray-100"></div>
<%= link_to users_path,
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
👥 Manage Users
<% end %>
<% end %>
<div class="border-t border-gray-100"></div>
<%= link_to session_path,
data: { turbo_method: :delete },
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
🚪 Sign Out
<% end %>
</div>
</div>
</div>
<% else %> <% else %>
<% if User.none? %> <% if User.none? %>
<li class="nav-item"> <%= link_to "👤 Create Admin Account", new_registration_path,
<%= link_to "Create Admin Account", new_registration_path, class: "nav-link btn btn-success text-white" %> class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors" %>
</li>
<% else %> <% else %>
<li class="nav-item"> <%= link_to "🔐 Sign In", new_session_path,
<%= link_to "Sign In", new_session_path, class: "nav-link" %> class: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors" %>
</li>
<% end %> <% end %>
<% end %> <% end %>
</ul>
<!-- Mobile menu button -->
<div class="md:hidden">
<button type="button"
data-controller="mobile-menu"
data-action="click->mobile-menu#toggle"
class="text-white hover:bg-gray-800 p-2 rounded-md">
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path data-mobile-menu-target="open" fill-rule="evenodd" d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z"/>
<path data-mobile-menu-target="close" class="hidden" fill-rule="evenodd" d="M18.278 16.864a1 1 0 01-1.414 1.414l-4.829-4.828-4.828 4.828a1 1 0 01-1.414-1.414l4.828-4.829-4.828-4.828a1 1 0 011.414-1.414l4.829 4.828 4.828-4.828a1 1 0 111.414 1.414l-4.828 4.829 4.828 4.828z"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu -->
<div data-mobile-menu-target="menu" class="hidden md:hidden border-t border-gray-800">
<div class="px-2 pt-2 pb-3 space-y-1">
<%= link_to "🔴 Events", events_path,
class: mobile_nav_link_class(events_path) %>
<%= link_to "⚙️ Rules", rules_path,
class: mobile_nav_link_class(rules_path) %>
<%= link_to "🌐 Network Ranges", network_ranges_path,
class: mobile_nav_link_class(network_ranges_path) %>
<% if user_signed_in? && current_user_admin? %>
<%= link_to "🔗 DSNs", dsns_path,
class: mobile_nav_link_class(dsns_path) %>
<% end %>
</div>
</div>
</nav>
</header>
<!-- Flash Messages -->
<% if notice || alert %>
<div class="fixed top-20 left-0 right-0 z-50 px-4 py-2">
<% if notice %>
<div class="flash-message bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<%= notice %>
</div>
</div>
<% end %>
<% if alert %>
<div class="flash-message bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-red-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<%= alert %>
</div>
</div>
<% end %>
</div>
<% end %>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<%= yield %>
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-gray-400 mt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="text-center">
<p class="text-sm">© <%= Time.current.year %> Baffle Hub - WAF Analytics Platform</p>
</div> </div>
</div> </div>
</nav> </footer>
<div class="container mt-4">
<% if notice %>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<%= notice %>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<% end %>
<% if alert %>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<%= alert %>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<% end %>
<%= yield %>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html class="bg-gray-50">
<head>
<title><%= content_for(:title) || "Baffle Hub - WAF Analytics" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="Baffle Hub">
<meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<style>
/* Custom styles for code blocks and badges */
code {
@apply bg-gray-100 px-2 py-1 rounded text-sm font-mono;
}
/* Flash message transitions */
.flash-message {
animation: slideInDown 0.3s ease-out;
}
@keyframes slideInDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>
</head>
<body class="min-h-screen bg-gray-50">
<!-- Minimal Navigation for Auth Pages -->
<%= render 'shared/auth_navigation' %>
<!-- Flash Messages -->
<% if notice || alert %>
<div class="fixed top-20 left-0 right-0 z-50 px-4 py-2">
<% if notice %>
<div class="flash-message bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<%= notice %>
</div>
</div>
<% end %>
<% if alert %>
<div class="flash-message bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-md shadow-sm max-w-4xl mx-auto">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-red-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<%= alert %>
</div>
</div>
<% end %>
</div>
<% end %>
<!-- Main Content -->
<main class="max-w-md mx-auto px-4 py-8">
<%= yield %>
</main>
<!-- Footer -->
<footer class="bg-white text-gray-500 mt-12 border-t border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="text-center">
<p class="text-sm">© <%= Time.current.year %> Baffle Hub - WAF Analytics Platform</p>
</div>
</div>
</footer>
</body>
</html>

View File

@@ -1,23 +1,21 @@
<% content_for :title, "Network Ranges - #{@project.name}" %> <% content_for :title, "Network Ranges" %>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <div>
<div> <h1 class="text-3xl font-bold text-gray-900">Network Ranges</h1>
<h1 class="text-3xl font-bold text-gray-900">Network Ranges</h1> <p class="mt-2 text-gray-600">Browse and manage network ranges with intelligence data</p>
<p class="mt-2 text-gray-600">Browse and manage network ranges with intelligence data</p> </div>
</div> <div class="flex space-x-3">
<div class="flex space-x-3"> <%= link_to "IP Lookup", lookup_network_ranges_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" %>
<%= link_to "IP Lookup", lookup_network_ranges_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" %> <%= link_to "Add Range", new_network_range_path, class: "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" %>
<%= link_to "Add Range", new_network_range_path, class: "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" %>
</div>
</div> </div>
</div> </div>
<!-- Active Filters --> <!-- Active Filters -->
<% if params[:asn].present? || params[:country].present? || params[:company].present? || params[:datacenter].present? || params[:vpn].present? || params[:proxy].present? || params[:source].present? || params[:search].present? %> <% if params[:asn].present? || params[:country].present? || params[:company].present? || params[:datacenter].present? || params[:vpn].present? || params[:proxy].present? || params[:source].present? || params[:search].present? %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> <div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h3 class="text-sm font-medium text-blue-900">Active Filters</h3> <h3 class="text-sm font-medium text-blue-900">Active Filters</h3>
@@ -225,8 +223,23 @@
<!-- Network Ranges Table --> <!-- Network Ranges Table -->
<div class="bg-white shadow overflow-hidden sm:rounded-md"> <div class="bg-white shadow overflow-hidden sm:rounded-md">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200"> <div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Network Ranges</h3> <div class="flex items-center justify-between">
<p class="mt-1 text-sm text-gray-500">Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges</p> <div>
<h3 class="text-lg font-medium text-gray-900">Network Ranges</h3>
<p class="mt-1 text-sm text-gray-500">Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges</p>
</div>
<% if @pagy.present? && @pagy.pages > 1 %>
<span class="text-sm text-gray-500">
Page <%= @pagy.page %> of <%= @pagy.pages %>
</span>
<% end %>
</div>
<!-- Top Pagination -->
<% if @pagy.present? && @pagy.pages > 1 %>
<div class="mt-4">
<%= pagy_nav_tailwind(@pagy, pagy_id: 'network_ranges_top') %>
</div>
<% end %>
</div> </div>
<ul class="divide-y divide-gray-200"> <ul class="divide-y divide-gray-200">
@@ -304,10 +317,10 @@
<% end %> <% end %>
</div> </div>
<!-- Pagination --> <!-- Bottom Pagination -->
<% if @pagy.present? %> <% if @pagy.present? && @pagy.pages > 1 %>
<div class="mt-6 flex justify-center"> <div class="mt-6">
<%= pagy_nav(@pagy) %> <%= pagy_nav_tailwind(@pagy, pagy_id: 'network_ranges_bottom') %>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -159,22 +159,22 @@
<h3 class="text-lg font-medium text-gray-900">Traffic Statistics</h3> <h3 class="text-lg font-medium text-gray-900">Traffic Statistics</h3>
</div> </div>
<div class="p-6"> <div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="text-center"> <div class="text-center min-w-0">
<div class="text-2xl font-bold text-gray-900"><%= number_with_delimiter(@traffic_stats[:total_requests]) %></div> <div class="text-2xl font-bold text-gray-900 break-words"><%= number_with_delimiter(@traffic_stats[:total_requests]) %></div>
<div class="text-sm text-gray-500">Total Requests</div> <div class="text-sm text-gray-500 whitespace-normal">Total Requests</div>
</div> </div>
<div class="text-center"> <div class="text-center min-w-0">
<div class="text-2xl font-bold text-gray-900"><%= number_with_delimiter(@traffic_stats[:unique_ips]) %></div> <div class="text-2xl font-bold text-gray-900 break-words"><%= number_with_delimiter(@traffic_stats[:unique_ips]) %></div>
<div class="text-sm text-gray-500">Unique IPs</div> <div class="text-sm text-gray-500 whitespace-normal">Unique IPs</div>
</div> </div>
<div class="text-center"> <div class="text-center min-w-0">
<div class="text-2xl font-bold text-green-600"><%= number_with_delimiter(@traffic_stats[:allowed_requests]) %></div> <div class="text-2xl font-bold text-green-600 break-words"><%= number_with_delimiter(@traffic_stats[:allowed_requests]) %></div>
<div class="text-sm text-gray-500">Allowed</div> <div class="text-sm text-gray-500 whitespace-normal">Allowed</div>
</div> </div>
<div class="text-center"> <div class="text-center min-w-0">
<div class="text-2xl font-bold text-red-600"><%= number_with_delimiter(@traffic_stats[:blocked_requests]) %></div> <div class="text-2xl font-bold text-red-600 break-words"><%= number_with_delimiter(@traffic_stats[:blocked_requests]) %></div>
<div class="text-sm text-gray-500">Blocked</div> <div class="text-sm text-gray-500 whitespace-normal">Blocked</div>
</div> </div>
</div> </div>
@@ -196,11 +196,197 @@
<% end %> <% end %>
<!-- Associated Rules --> <!-- Associated Rules -->
<% if @associated_rules.any? %> <div class="bg-white shadow rounded-lg mb-6">
<div class="bg-white shadow rounded-lg mb-6"> <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">
<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>
<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">
<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" />
</svg>
Quick Create Rule
</button>
</div> </div>
</div>
<!-- Quick Create Rule Form -->
<div id="quick_create_rule" class="hidden border-b border-gray-200">
<div class="px-6 py-4 bg-blue-50">
<%= form_with(model: Rule.new, url: rules_path, local: true,
class: "space-y-4",
data: { turbo: false }) do |form| %>
<!-- Hidden network range ID -->
<%= form.hidden_field :network_range_id, value: @network_range.id %>
<!-- Network Context Display -->
<div class="mb-4 p-3 bg-blue-100 border border-blue-200 rounded-md">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<div class="text-sm text-blue-800">
<strong>Creating rule for:</strong> <%= @network_range.cidr %>
<% if @network_range.company.present? %>
- <%= @network_range.company %>
<% end %>
<% if @network_range.asn_org.present? %>
(ASN: <%= @network_range.asn_org %>)
<% end %>
<% if @network_range.is_datacenter? || @network_range.is_vpn? || @network_range.is_proxy? %>
<span class="ml-2">
<% if @network_range.is_datacenter? %><span class="bg-orange-200 px-2 py-0.5 rounded text-xs">Datacenter</span><% end %>
<% if @network_range.is_vpn? %><span class="bg-purple-200 px-2 py-0.5 rounded text-xs">VPN</span><% end %>
<% if @network_range.is_proxy? %><span class="bg-red-200 px-2 py-0.5 rounded text-xs">Proxy</span><% end %>
</span>
<% end %>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Rule Type -->
<div>
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :rule_type,
options_for_select([
['Network - IP/CIDR based blocking', 'network'],
['Rate Limit - Request rate limiting', 'rate_limit'],
['Path Pattern - URL path filtering', 'path_pattern'],
['Header Pattern - HTTP header filtering', 'header_pattern'],
['Query Pattern - Query parameter filtering', 'query_pattern'],
['Body Signature - Request body filtering', 'body_signature']
], 'network'),
{ },
{
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",
onchange: "toggleRuleTypeFields()"
} %>
<p class="mt-1 text-xs text-gray-500">Select the type of rule to create</p>
</div>
<!-- Action -->
<div>
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :action,
options_for_select([
['Deny - Block requests', 'deny'],
['Allow - Whitelist requests', 'allow'],
['Rate Limit - Throttle requests', 'rate_limit'],
['Redirect - Redirect to URL', 'redirect'],
['Challenge - Present CAPTCHA', 'challenge'],
['Monitor - Log but allow', 'monitor']
], '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" } %>
<p class="mt-1 text-xs text-gray-500">Action to take when rule matches</p>
</div>
</div>
<!-- Expires At -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= form.label :expires_at, "Expires At (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.datetime_local_field :expires_at,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">Leave blank for permanent rule</p>
</div>
<div class="text-sm text-gray-600 flex items-center pt-6">
<svg class="w-4 h-4 text-blue-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
Priority will be calculated automatically based on rule type
</div>
</div>
<!-- Pattern-based Rule Fields -->
<div id="pattern_fields" class="hidden space-y-4">
<div>
<%= form.label :conditions, "Pattern/Conditions", class: "block text-sm font-medium text-gray-700" %>
<%= 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",
placeholder: "Enter pattern or JSON conditions...",
id: "quick_conditions_field" %>
<p class="mt-1 text-xs text-gray-500" id="pattern_help_text">Pattern will be used for matching</p>
</div>
</div>
<!-- Rate Limit Fields -->
<div id="rate_limit_fields" class="hidden space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= label_tag :rate_limit, "Request Limit", class: "block text-sm font-medium text-gray-700" %>
<%= number_field_tag :rate_limit, 10,
min: 1,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">Maximum requests per time window</p>
</div>
<div>
<%= label_tag :rate_window, "Time Window (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= number_field_tag :rate_window, 60,
min: 1,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">Time window in seconds</p>
</div>
</div>
</div>
<!-- Redirect Fields -->
<div id="redirect_fields" class="hidden space-y-4">
<div>
<%= label_tag :redirect_url, "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag :redirect_url,
"https://example.com/blocked",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">URL to redirect to when rule matches</p>
</div>
</div>
<!-- Metadata -->
<div>
<%= form.label :metadata, "Reason/Description", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :metadata,
placeholder: "e.g., Block malicious traffic from this range",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">Human-readable description of why this rule exists</p>
</div>
<!-- Source -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :source,
options_for_select([
['Manual', 'manual'],
['Auto-detected', 'auto'],
['Hub Sync', 'hub'],
['Imported', 'imported']
], 'manual'),
{ },
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
</div>
<div class="flex items-center pt-6">
<%= form.check_box :enabled, checked: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :enabled, "Enable immediately", class: "ml-2 block text-sm text-gray-900" %>
</div>
</div>
<!-- Form Actions -->
<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">
Cancel
</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" %>
</div>
<% end %>
</div>
</div>
<!-- Rules List -->
<% if @associated_rules.any? %>
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
<% @associated_rules.each do |rule| %> <% @associated_rules.each do |rule| %>
<div class="px-6 py-4"> <div class="px-6 py-4">
@@ -214,6 +400,9 @@
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Priority: <%= rule.priority %> Priority: <%= rule.priority %>
</span> </span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= rule.rule_type.humanize %>
</span>
<% if rule.source.include?('surgical') %> <% if rule.source.include?('surgical') %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Surgical Surgical
@@ -242,8 +431,16 @@
</div> </div>
<% end %> <% end %>
</div> </div>
</div> <% else %>
<% end %> <div class="px-6 py-8 text-center text-gray-500">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No rules yet</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a rule for this network range.</p>
</div>
<% end %>
</div>
<!-- Network Relationships --> <!-- Network Relationships -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
@@ -344,4 +541,87 @@
</div> </div>
</div> </div>
<% 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>

View File

@@ -1,200 +0,0 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><%= @project.name %> - Analytics</h1>
<div>
<%= link_to "← Back to Project", project_path(@project), class: "btn btn-secondary" %>
</div>
</div>
<!-- Time Range Selector -->
<div class="card mb-4">
<div class="card-header">
<h5>Time Range</h5>
</div>
<div class="card-body">
<%= form_with url: analytics_project_path(@project), method: :get, local: true do |form| %>
<div class="row align-items-end">
<div class="col-md-6">
<%= form.label :time_range, "Time Range", class: "form-label" %>
<%= form.select :time_range,
options_for_select([
["Last Hour", 1],
["Last 6 Hours", 6],
["Last 24 Hours", 24],
["Last 7 Days", 168],
["Last 30 Days", 720]
], @time_range),
{}, class: "form-select" %>
</div>
<div class="col-md-6">
<%= form.submit "Update", class: "btn btn-primary" %>
</div>
</div>
<% end %>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary"><%= number_with_delimiter(@total_events) %></h3>
<p class="card-text">Total Events</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="text-success"><%= number_with_delimiter(@allowed_events) %></h3>
<p class="card-text">Allowed</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="text-danger"><%= number_with_delimiter(@blocked_events) %></h3>
<p class="card-text">Blocked</p>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Top Blocked IPs -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>Top Blocked IPs</h5>
</div>
<div class="card-body">
<% if @top_blocked_ips.any? %>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Blocked Count</th>
</tr>
</thead>
<tbody>
<% @top_blocked_ips.each do |stat| %>
<tr>
<td><code><%= stat.ip_address %></code></td>
<td><%= number_with_delimiter(stat.count) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="text-muted">No blocked events in this time range.</p>
<% end %>
</div>
</div>
</div>
<!-- Country Distribution -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>Top Countries</h5>
</div>
<div class="card-body">
<% if @country_stats.any? %>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Country</th>
<th>Events</th>
</tr>
</thead>
<tbody>
<% @country_stats.each do |stat| %>
<tr>
<td><%= stat.country_code || 'Unknown' %></td>
<td><%= number_with_delimiter(stat.count) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="text-muted">No country data available.</p>
<% end %>
</div>
</div>
</div>
</div>
<!-- Action Distribution -->
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5>Action Distribution</h5>
</div>
<div class="card-body">
<% if @action_stats.any? %>
<div class="row">
<% @action_stats.each do |stat| %>
<div class="col-md-3 text-center mb-3">
<div class="card">
<div class="card-body">
<h4><%= stat.action.upcase %></h4>
<p class="card-text">
<span class="badge bg-<%=
case stat.action
when 'allow', 'pass' then 'success'
when 'block', 'deny' then 'danger'
when 'challenge' then 'warning'
when 'rate_limit' then 'info'
else 'secondary'
end %>">
<%= number_with_delimiter(stat.count) %>
</span>
</p>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="text-muted">No action data available.</p>
<% end %>
</div>
</div>
</div>
</div>
<% if @total_events > 0 %>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5>Block Rate</h5>
</div>
<div class="card-body">
<div class="progress" style="height: 30px;">
<% blocked_percentage = (@blocked_events.to_f / @total_events * 100).round(1) %>
<% allowed_percentage = (@allowed_events.to_f / @total_events * 100).round(1) %>
<div class="progress-bar bg-success" style="width: <%= allowed_percentage %>%">
<%= allowed_percentage %>% Allowed
</div>
<div class="progress-bar bg-danger" style="width: <%= blocked_percentage %>%">
<%= blocked_percentage %>% Blocked
</div>
</div>
</div>
</div>
</div>
</div>
<% end %>
<div class="mt-4">
<%= link_to "View Events", events_project_path(@project), class: "btn btn-primary" %>
<%= link_to "Export Data", "#", class: "btn btn-secondary", onclick: "alert('Export feature coming soon!')" %>
</div>

View File

@@ -1,49 +0,0 @@
<h1>Projects</h1>
<%= link_to "New Project", new_project_path, class: "btn btn-primary mb-3" %>
<div class="row">
<% @projects.each do |project| %>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><%= project.name %></h5>
<span class="badge <%= project.enabled? ? 'bg-success' : 'bg-secondary' %>">
<%= project.enabled? ? 'Active' : 'Disabled' %>
</span>
</div>
<div class="card-body">
<p class="card-text">
<strong>Status:</strong>
<span class="badge bg-<%= project.waf_status == 'active' ? 'success' : project.waf_status == 'idle' ? 'warning' : 'danger' %>">
<%= project.waf_status %>
</span>
</p>
<p class="card-text">
<strong>Events (24h):</strong> <%= project.event_count(24.hours.ago) %>
</p>
<p class="card-text">
<strong>Blocked (24h):</strong> <%= project.blocked_count(24.hours.ago) %>
</p>
<small class="text-muted">
<strong>DSN:</strong><br>
<code><%= project.dsn %></code>
</small>
</div>
<div class="card-footer">
<%= link_to "View", project_path(project), class: "btn btn-primary btn-sm" %>
<%= link_to "Events", project_events_path(project), class: "btn btn-secondary btn-sm" %>
<%= link_to "Analytics", analytics_project_path(project), class: "btn btn-info btn-sm" %>
</div>
</div>
</div>
<% end %>
</div>
<% if @projects.empty? %>
<div class="text-center my-5">
<h3>No projects yet</h3>
<p>Create your first project to start monitoring WAF events.</p>
<%= link_to "Create Project", new_project_path, class: "btn btn-primary" %>
</div>
<% end %>

View File

@@ -1,32 +0,0 @@
<h1>New Project</h1>
<%= form_with(model: @project, local: true) do |form| %>
<% if @project.errors.any? %>
<div class="alert alert-danger">
<h4><%= pluralize(@project.errors.count, "error") %> prohibited this project from being saved:</h4>
<ul>
<% @project.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="mb-3">
<%= form.label :name, class: "form-label" %>
<%= form.text_field :name, class: "form-control" %>
</div>
<div class="mb-3">
<%= form.label :enabled, class: "form-label" %>
<div class="form-check">
<%= form.check_box :enabled, class: "form-check-input" %>
<%= form.label :enabled, "Enable this project", class: "form-check-label" %>
</div>
</div>
<div class="mb-3">
<%= form.submit "Create Project", class: "btn btn-primary" %>
<%= link_to "Cancel", projects_path, class: "btn btn-secondary" %>
</div>
<% end %>

View File

@@ -1,118 +0,0 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><%= @project.name %></h1>
<div>
<%= link_to "Edit", edit_project_path(@project), class: "btn btn-secondary" %>
<%= link_to "Events", project_events_path(@project), class: "btn btn-primary" %>
<%= link_to "Analytics", analytics_project_path(@project), class: "btn btn-info" %>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>Project Status</h5>
</div>
<div class="card-body">
<p><strong>Status:</strong>
<span class="badge bg-<%= @waf_status == 'active' ? 'success' : @waf_status == 'idle' ? 'warning' : 'danger' %>">
<%= @waf_status %>
</span>
</p>
<p><strong>Enabled:</strong>
<span class="badge bg-<%= @project.enabled? ? 'success' : 'secondary' %>">
<%= @project.enabled? ? 'Yes' : 'No' %>
</span>
</p>
<p><strong>Events (24h):</strong> <%= @event_count %></p>
<p><strong>Blocked (24h):</strong> <%= @blocked_count %></p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>DSN Configuration</h5>
</div>
<div class="card-body">
<p><strong>DSN:</strong></p>
<code><%= @project.dsn %></code>
<button class="btn btn-sm btn-outline-primary ms-2" onclick="copyDSN()">Copy</button>
<% if @project.internal_dsn.present? %>
<hr>
<p><strong>Internal DSN:</strong></p>
<code><%= @project.internal_dsn %></code>
<% end %>
</div>
</div>
</div>
</div>
<div class="mt-4">
<div class="card">
<div class="card-header">
<h5>Recent Events</h5>
</div>
<div class="card-body">
<% if @recent_events.any? %>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>Action</th>
<th>Path</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% @recent_events.limit(5).each do |event| %>
<tr>
<td><%= event.timestamp.strftime("%H:%M:%S") %></td>
<td><%= event.ip_address %></td>
<td>
<span class="badge bg-<%= event.blocked? ? 'danger' : event.allowed? ? 'success' : 'warning' %>">
<%= event.waf_action %>
</span>
</td>
<td><code><%= event.request_path %></code></td>
<td><%= event.response_status %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="text-end">
<%= link_to "View All Events", project_events_path(@project), class: "btn btn-primary btn-sm" %>
</div>
<% else %>
<p class="text-muted">No events received yet.</p>
<% end %>
</div>
</div>
</div>
<script>
function copyDSN() {
const dsnElement = document.querySelector('code');
const textArea = document.createElement('textarea');
textArea.value = dsnElement.textContent;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
// Show feedback
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('btn-success');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('btn-success');
}, 2000);
}
</script>

View File

@@ -1,22 +1,19 @@
<% content_for :title, "Rules - #{@project.name}" %> <% content_for :title, "Rules" %>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <div>
<div> <h1 class="text-3xl font-bold text-gray-900">Rules</h1>
<h1 class="text-3xl font-bold text-gray-900">Rules</h1> <p class="mt-2 text-gray-600">Manage WAF rules for traffic filtering and control</p>
<p class="mt-2 text-gray-600">Manage WAF rules for traffic filtering and control</p> </div>
</div> <div class="flex space-x-3">
<div class="flex space-x-3"> <%= link_to "Create Rule", new_rule_path, class: "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" %>
<%= link_to "Add Network Range", new_network_range_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" %>
<%= link_to "Create Rule", new_rule_path, class: "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" %>
</div>
</div> </div>
</div> </div>
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<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">
<div class="flex items-center"> <div class="flex items-center">
@@ -93,7 +90,20 @@
<!-- Rules List --> <!-- Rules List -->
<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">
<h3 class="text-lg font-medium text-gray-900">All Rules</h3> <div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">All Rules (<%= number_with_delimiter(@rules.count) %>)</h3>
<% if @pagy.present? && @pagy.pages > 1 %>
<span class="text-sm text-gray-500">
Page <%= @pagy.page %> of <%= @pagy.pages %>
</span>
<% end %>
</div>
<!-- Top Pagination -->
<% if @pagy.present? && @pagy.pages > 1 %>
<div class="mt-4">
<%= pagy_nav_tailwind(@pagy, pagy_id: 'rules_top') %>
</div>
<% end %>
</div> </div>
<% if @rules.any? %> <% if @rules.any? %>
@@ -178,7 +188,7 @@
<% if rule.expires_at.present? %> <% if rule.expires_at.present? %>
<span class="text-xs text-gray-500" title="Expires at <%= rule.expires_at.strftime('%Y-%m-%d %H:%M') %>"> <span class="text-xs text-gray-500" title="Expires at <%= rule.expires_at.strftime('%Y-%m-%d %H:%M') %>">
<%= distance_of_time_in_words(Time.current, rule.expires_at) %> left Expires <%= time_ago_in_words(rule.expires_at) %> from now
</span> </span>
<% end %> <% end %>
</div> </div>
@@ -221,4 +231,11 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<!-- Bottom Pagination -->
<% if @pagy.present? && @pagy.pages > 1 %>
<div class="mt-6">
<%= pagy_nav_tailwind(@pagy, pagy_id: 'rules_bottom') %>
</div>
<% end %>
</div> </div>

View File

@@ -0,0 +1,24 @@
<!-- Minimal Navigation Header for Authentication Pages -->
<header class="bg-white border-b border-gray-200">
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
<div class="flex items-center">
<%= link_to root_path, class: "flex-shrink-0 flex items-center" do %>
<svg class="h-8 w-8 text-blue-500 mr-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
</svg>
<span class="text-gray-900 text-lg font-bold">Baffle Hub</span>
<% end %>
</div>
<!-- Right side - optional help link -->
<div class="flex items-center">
<% if User.any? %>
<%= link_to "🏠 Back to Dashboard", root_path,
class: "text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium transition-colors" %>
<% end %>
</div>
</div>
</nav>
</header>

View File

@@ -18,7 +18,10 @@ postgres_default: &postgres_default
development: development:
primary: primary:
<<: *postgres_default <<: *postgres_default
database: baffle_hub_development host: localhost
user: dev_user
password: password
database: baffle-dev
cache: cache:
<<: *sqlite_default <<: *sqlite_default
database: storage/development_cache.sqlite3 database: storage/development_cache.sqlite3
@@ -66,4 +69,4 @@ production:
cable: cable:
<<: *sqlite_default <<: *sqlite_default
database: storage/production_cable.sqlite3 database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate migrations_paths: db/cable_migrate

View File

@@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
# Pagy configuration # Pagy configuration
# require 'pagy' # Initialize Pagy with default settings after it's loaded
# Pagy::VARS[:items] = 50 # default items per page Rails.application.config.after_initialize do
# Set default items per page
Pagy::VARS[:items] = 25 if defined?(Pagy::VARS)
end

View File

@@ -11,6 +11,14 @@ 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]
# DSN management (admin only)
resources :dsns do
member do
post :disable
post :enable
end
end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
@@ -20,24 +28,22 @@ Rails.application.routes.draw do
# WAF API # WAF API
namespace :api, defaults: { format: :json } do namespace :api, defaults: { format: :json } do
# Event ingestion (PRIMARY method - includes rule updates in response) # Event ingestion (PRIMARY method - includes rule updates in response)
post ":project_id/events", to: "events#create" post "events", to: "events#create"
# Rule synchronization (SECONDARY - for admin/debugging only) # Rule synchronization (SECONDARY - for admin/debugging only)
# Note: Agents should use event responses for rule synchronization # Note: Agents should use event responses for rule synchronization
get ":public_key/rules/version", to: "rules#version" get "rules/version", to: "rules#version"
get ":public_key/rules", to: "rules#index" get "rules", to: "rules#index"
end end
# Root path - projects dashboard # Analytics dashboard
root "projects#index" get "analytics", to: "analytics#index"
# Project management # Root path - analytics dashboard
resources :projects, only: [:index, :new, :create, :show, :edit, :update] do root "analytics#index"
resources :events, only: [:index]
member do # Event management
get :analytics resources :events, only: [:index]
end
end
# 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

View File

@@ -0,0 +1,12 @@
class CreateDsns < ActiveRecord::Migration[8.1]
def change
create_table :dsns do |t|
t.string :key
t.string :name
t.boolean :enabled, default: true, null: false
t.timestamps
end
add_index :dsns, :key, unique: true
end
end

View File

@@ -0,0 +1,5 @@
class RemoveProjectIdFromEvents < ActiveRecord::Migration[8.1]
def change
remove_reference :events, :project, null: false, foreign_key: true
end
end

View File

@@ -0,0 +1,5 @@
class DropProjectsTable < ActiveRecord::Migration[8.1]
def change
drop_table :projects
end
end

View File

@@ -0,0 +1,67 @@
class AddEventsCountToNetworkRanges < ActiveRecord::Migration[8.1]
def up
# Add the column with default value
add_column :network_ranges, :events_count, :integer, null: false, default: 0
# Add index for faster queries
add_index :network_ranges, :events_count
# Create trigger function to update counter cache
execute <<-SQL
CREATE OR REPLACE FUNCTION update_network_range_events_count()
RETURNS TRIGGER AS $$
BEGIN
-- Update all network ranges that contain the IP address
UPDATE network_ranges
SET events_count = events_count +
CASE
WHEN TG_OP = 'INSERT' THEN 1
WHEN TG_OP = 'DELETE' THEN -1
ELSE 0
END
WHERE network >>= NEW.ip_address::inet;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
SQL
# Create triggers for events table
execute <<-SQL
CREATE TRIGGER update_network_ranges_events_count_after_insert
AFTER INSERT ON events
FOR EACH ROW
EXECUTE FUNCTION update_network_range_events_count();
SQL
execute <<-SQL
CREATE TRIGGER update_network_ranges_events_count_after_delete
AFTER DELETE ON events
FOR EACH ROW
EXECUTE FUNCTION update_network_range_events_count();
SQL
# Backfill existing counts
execute <<-SQL
UPDATE network_ranges
SET events_count = (
SELECT COUNT(*)
FROM events
WHERE events.ip_address <<= network_ranges.network
);
SQL
end
def down
# Drop triggers first
execute <<-SQL
DROP TRIGGER IF EXISTS update_network_ranges_events_count_after_insert ON events;
DROP TRIGGER IF EXISTS update_network_ranges_events_count_after_delete ON events;
DROP FUNCTION IF EXISTS update_network_range_events_count();
SQL
# Remove column and index
remove_index :network_ranges, :events_count
remove_column :network_ranges, :events_count
end
end

View File

@@ -0,0 +1,20 @@
class CreateDefaultDsn < ActiveRecord::Migration[8.1]
def change
reversible do |dir|
dir.up do
# Only create if no DSNs exist
if Dsn.count == 0
Dsn.create!(
name: 'Development DSN',
key: 'dev-test-key-1234567890abcdef',
enabled: true
)
end
end
dir.down do
# Remove the default DSN if it exists
Dsn.where(key: 'dev-test-key-1234567890abcdef').delete_all
end
end
end
end

View File

@@ -10,10 +10,19 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 7) do ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
create_table "dsns", force: :cascade do |t|
t.datetime "created_at", null: false
t.boolean "enabled", default: true, null: false
t.string "key"
t.string "name"
t.datetime "updated_at", null: false
t.index ["key"], name: "index_dsns_on_key", unique: true
end
create_table "events", force: :cascade do |t| create_table "events", force: :cascade do |t|
t.string "agent_name" t.string "agent_name"
t.string "agent_version" t.string "agent_version"
@@ -25,7 +34,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do
t.string "event_id", null: false t.string "event_id", null: false
t.inet "ip_address" t.inet "ip_address"
t.json "payload" t.json "payload"
t.bigint "project_id", null: false
t.bigint "request_host_id" t.bigint "request_host_id"
t.integer "request_method", default: 0 t.integer "request_method", default: 0
t.string "request_path" t.string "request_path"
@@ -42,10 +50,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do
t.integer "waf_action", default: 0, null: false t.integer "waf_action", default: 0, null: false
t.index ["event_id"], name: "index_events_on_event_id", unique: true t.index ["event_id"], name: "index_events_on_event_id", unique: true
t.index ["ip_address"], name: "index_events_on_ip_address" t.index ["ip_address"], name: "index_events_on_ip_address"
t.index ["project_id", "ip_address"], name: "idx_events_project_ip"
t.index ["project_id", "timestamp"], name: "idx_events_project_time"
t.index ["project_id", "waf_action"], name: "idx_events_project_action"
t.index ["project_id"], name: "index_events_on_project_id"
t.index ["request_host_id", "request_method", "request_segment_ids"], name: "idx_events_host_method_path" t.index ["request_host_id", "request_method", "request_segment_ids"], name: "idx_events_host_method_path"
t.index ["request_host_id"], name: "index_events_on_request_host_id" t.index ["request_host_id"], name: "index_events_on_request_host_id"
t.index ["request_segment_ids"], name: "index_events_on_request_segment_ids" t.index ["request_segment_ids"], name: "index_events_on_request_segment_ids"
@@ -62,6 +66,7 @@ ActiveRecord::Schema[8.1].define(version: 7) do
t.string "country" t.string "country"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.text "creation_reason" t.text "creation_reason"
t.integer "events_count"
t.boolean "is_datacenter", default: false t.boolean "is_datacenter", default: false
t.boolean "is_proxy", default: false t.boolean "is_proxy", default: false
t.boolean "is_vpn", default: false t.boolean "is_vpn", default: false
@@ -91,23 +96,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do
t.index ["segment"], name: "index_path_segments_on_segment", unique: true t.index ["segment"], name: "index_path_segments_on_segment", unique: true
end end
create_table "projects", force: :cascade do |t|
t.integer "blocked_ip_count", default: 0, null: false
t.datetime "created_at", null: false
t.text "custom_rules", default: "{}", null: false
t.boolean "enabled", default: true, null: false
t.string "name", null: false
t.string "public_key", null: false
t.integer "rate_limit_threshold", default: 100, null: false
t.text "settings", default: "{}", null: false
t.string "slug", null: false
t.datetime "updated_at", null: false
t.index ["enabled"], name: "index_projects_on_enabled"
t.index ["name"], name: "index_projects_on_name"
t.index ["public_key"], name: "index_projects_on_public_key", unique: true
t.index ["slug"], name: "index_projects_on_slug", unique: true
end
create_table "request_actions", force: :cascade do |t| create_table "request_actions", force: :cascade do |t|
t.string "action", null: false t.string "action", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -182,7 +170,6 @@ ActiveRecord::Schema[8.1].define(version: 7) do
t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["email_address"], name: "index_users_on_email_address", unique: true
end end
add_foreign_key "events", "projects"
add_foreign_key "events", "request_hosts" add_foreign_key "events", "request_hosts"
add_foreign_key "network_ranges", "users" add_foreign_key "network_ranges", "users"
add_foreign_key "rules", "network_ranges" add_foreign_key "rules", "network_ranges"

View File

@@ -5,10 +5,9 @@ require "test_helper"
module Api module Api
class RulesControllerTest < ActionDispatch::IntegrationTest class RulesControllerTest < ActionDispatch::IntegrationTest
setup do setup do
@project = Project.create!( @dsn = Dsn.create!(
name: "Test Project", name: "Test DSN",
slug: "test-project", key: "test-key-#{SecureRandom.hex(8)}"
public_key: "test-key-#{SecureRandom.hex(8)}"
) )
@rule1 = Rule.create!( @rule1 = Rule.create!(
@@ -27,7 +26,7 @@ module Api
end end
test "version endpoint returns correct structure" do test "version endpoint returns correct structure" do
get "/api/#{@project.public_key}/rules/version" get "/api/rules/version", headers: { "Authorization" => "Bearer #{@dsn.key}" }
assert_response :success assert_response :success
@@ -45,21 +44,21 @@ module Api
assert_response :unauthorized assert_response :unauthorized
json = JSON.parse(response.body) json = JSON.parse(response.body)
assert_equal "Invalid project key", json["error"] assert_equal "Invalid DSN key", json["error"]
end end
test "version endpoint rejects disabled projects" do test "version endpoint rejects disabled projects" do
@project.update!(enabled: false) @dsn.update!(enabled: false)
get "/api/#{@project.public_key}/rules/version" get "/api/rules/version"
assert_response :forbidden assert_response :forbidden
json = JSON.parse(response.body) json = JSON.parse(response.body)
assert_equal "Project is disabled", json["error"] assert_equal "DSN is disabled", json["error"]
end end
test "index endpoint returns all active rules" do test "index endpoint returns all active rules" do
get "/api/#{@project.public_key}/rules" get "/api/rules"
assert_response :success assert_response :success
@@ -78,7 +77,7 @@ module Api
test "index endpoint excludes disabled rules" do test "index endpoint excludes disabled rules" do
@rule1.update!(enabled: false) @rule1.update!(enabled: false)
get "/api/#{@project.public_key}/rules" get "/api/rules"
assert_response :success assert_response :success
@@ -90,7 +89,7 @@ module Api
test "index endpoint excludes expired rules" do test "index endpoint excludes expired rules" do
@rule1.update!(expires_at: 1.hour.ago) @rule1.update!(expires_at: 1.hour.ago)
get "/api/#{@project.public_key}/rules" get "/api/rules"
assert_response :success assert_response :success
@@ -104,7 +103,7 @@ module Api
@rule1.update_column(:updated_at, 2.hours.ago) @rule1.update_column(:updated_at, 2.hours.ago)
since_time = 1.hour.ago.iso8601 since_time = 1.hour.ago.iso8601
get "/api/#{@project.public_key}/rules?since=#{since_time}" get "/api/rules?since=#{since_time}"
assert_response :success assert_response :success
@@ -117,7 +116,7 @@ module Api
@rule1.update!(enabled: false) # This updates updated_at @rule1.update!(enabled: false) # This updates updated_at
since_time = 1.minute.ago.iso8601 since_time = 1.minute.ago.iso8601
get "/api/#{@project.public_key}/rules?since=#{since_time}" get "/api/rules?since=#{since_time}"
assert_response :success assert_response :success
@@ -130,7 +129,7 @@ module Api
end end
test "index endpoint with invalid timestamp returns error" do test "index endpoint with invalid timestamp returns error" do
get "/api/#{@project.public_key}/rules?since=invalid-timestamp" get "/api/rules?since=invalid-timestamp"
assert_response :bad_request assert_response :bad_request
json = JSON.parse(response.body) json = JSON.parse(response.body)
@@ -144,7 +143,7 @@ module Api
end end
test "index endpoint includes sampling information" do test "index endpoint includes sampling information" do
get "/api/#{@project.public_key}/rules" get "/api/rules"
assert_response :success assert_response :success
@@ -180,7 +179,7 @@ module Api
conditions: { cidr: "192.168.3.0/24" } conditions: { cidr: "192.168.3.0/24" }
) )
get "/api/#{@project.public_key}/rules?since=#{4.hours.ago.iso8601}" get "/api/rules?since=#{4.hours.ago.iso8601}"
assert_response :success assert_response :success

View File

@@ -4,7 +4,7 @@ require "test_helper"
class PathScannerDetectorJobTest < ActiveJob::TestCase class PathScannerDetectorJobTest < ActiveJob::TestCase
setup do setup do
@project = Project.first || Project.create!( @project = Project.first || #Project.create!(
name: "Test Project", name: "Test Project",
slug: "test-project", slug: "test-project",
public_key: SecureRandom.hex(16) public_key: SecureRandom.hex(16)

View File

@@ -4,7 +4,6 @@ require "test_helper"
class EventTest < ActiveSupport::TestCase class EventTest < ActiveSupport::TestCase
def setup def setup
@project = Project.create!(name: "Test Project", slug: "test-project")
@sample_payload = { @sample_payload = {
"event_id" => "test-event-123", "event_id" => "test-event-123",
"timestamp" => Time.now.iso8601, "timestamp" => Time.now.iso8601,
@@ -41,14 +40,12 @@ class EventTest < ActiveSupport::TestCase
def teardown def teardown
Event.delete_all # Delete events first to avoid foreign key constraints Event.delete_all # Delete events first to avoid foreign key constraints
Project.delete_all
end end
test "create_from_waf_payload! creates event with proper enum values" do test "create_from_waf_payload! creates event with proper enum values" do
event = Event.create_from_waf_payload!("test-123", @sample_payload, @project) event = Event.create_from_waf_payload!("test-123", @sample_payload)
assert event.persisted? assert event.persisted?
assert_equal @project, event.project
assert_equal "test-123", event.event_id assert_equal "test-123", event.event_id
assert_equal "192.168.1.1", event.ip_address assert_equal "192.168.1.1", event.ip_address
assert_equal "/api/test", event.request_path assert_equal "/api/test", event.request_path
@@ -71,7 +68,7 @@ class EventTest < ActiveSupport::TestCase
payload["request"]["method"] = method payload["request"]["method"] = method
payload["event_id"] = "test-method-#{method.downcase}" payload["event_id"] = "test-method-#{method.downcase}"
event = Event.create_from_waf_payload!("test-method-#{method.downcase}", payload, @project) event = Event.create_from_waf_payload!("test-method-#{method.downcase}", payload)
assert_equal expected_enums[index].to_s, event.request_method, assert_equal expected_enums[index].to_s, event.request_method,
"Method #{method} should map to enum #{expected_enums[index]}" "Method #{method} should map to enum #{expected_enums[index]}"
@@ -96,7 +93,7 @@ class EventTest < ActiveSupport::TestCase
payload["waf_action"] = action payload["waf_action"] = action
payload["event_id"] = "test-action-#{action}" payload["event_id"] = "test-action-#{action}"
event = Event.create_from_waf_payload!("test-action-#{action}", payload, @project) event = Event.create_from_waf_payload!("test-action-#{action}", payload)
assert_equal expected_enum.to_s, event.waf_action, assert_equal expected_enum.to_s, event.waf_action,
"Action #{action} should map to enum #{expected_enum}" "Action #{action} should map to enum #{expected_enum}"
@@ -113,7 +110,7 @@ class EventTest < ActiveSupport::TestCase
"CONTENT-TYPE" => "application/json" "CONTENT-TYPE" => "application/json"
} }
event = Event.create_from_waf_payload!("test-headers", payload, @project) event = Event.create_from_waf_payload!("test-headers", payload)
assert_equal "TestAgent/1.0", event.user_agent assert_equal "TestAgent/1.0", event.user_agent
# The normalize_payload_headers method should normalize header keys to lowercase # The normalize_payload_headers method should normalize header keys to lowercase
@@ -123,7 +120,7 @@ class EventTest < ActiveSupport::TestCase
end end
test "enum values persist after save and reload" do test "enum values persist after save and reload" do
event = Event.create_from_waf_payload!("test-persist", @sample_payload, @project) event = Event.create_from_waf_payload!("test-persist", @sample_payload)
# Verify initial values # Verify initial values
assert_equal "get", event.request_method assert_equal "get", event.request_method
@@ -232,7 +229,7 @@ class EventTest < ActiveSupport::TestCase
end end
test "payload extraction methods work correctly" do test "payload extraction methods work correctly" do
event = Event.create_from_waf_payload!("extraction-test", @sample_payload, @project) event = Event.create_from_waf_payload!("extraction-test", @sample_payload)
# Test request_details # Test request_details
request_details = event.request_details request_details = event.request_details
@@ -258,7 +255,7 @@ class EventTest < ActiveSupport::TestCase
end end
test "helper methods work correctly" do test "helper methods work correctly" do
event = Event.create_from_waf_payload!("helper-test", @sample_payload, @project) event = Event.create_from_waf_payload!("helper-test", @sample_payload)
# Test boolean methods # Test boolean methods
assert event.allowed? assert event.allowed?
@@ -284,7 +281,7 @@ class EventTest < ActiveSupport::TestCase
payload["timestamp"] = timestamp payload["timestamp"] = timestamp
payload["event_id"] = "timestamp-test-#{index}" payload["event_id"] = "timestamp-test-#{index}"
event = Event.create_from_waf_payload!("timestamp-test-#{index}", payload, @project) event = Event.create_from_waf_payload!("timestamp-test-#{index}", payload)
assert event.timestamp.is_a?(Time), "Timestamp #{index} should be parsed as Time" assert event.timestamp.is_a?(Time), "Timestamp #{index} should be parsed as Time"
assert_not event.timestamp.nil? assert_not event.timestamp.nil?
end end
@@ -304,7 +301,7 @@ class EventTest < ActiveSupport::TestCase
} }
} }
event = Event.create_from_waf_payload!("minimal-test", minimal_payload, @project) event = Event.create_from_waf_payload!("minimal-test", minimal_payload)
assert event.persisted? assert event.persisted?
assert_equal "10.0.0.1", event.ip_address assert_equal "10.0.0.1", event.ip_address