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

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

View File

@@ -7,11 +7,11 @@ module Api
# These endpoints are kept for administrative/debugging purposes only
skip_before_action :verify_authenticity_token
allow_unauthenticated_access # Skip normal session auth, use project key auth instead
before_action :authenticate_project!
before_action :check_project_enabled
allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
before_action :authenticate_dsn!
before_action :check_dsn_enabled
# GET /api/:public_key/rules/version
# GET /api/rules/version
# Quick version check - returns latest updated_at timestamp
def version
current_sampling = HubLoad.current_sampling
@@ -24,9 +24,9 @@ module Api
}
end
# GET /api/:public_key/rules?since=1730646186
# GET /api/rules?since=1730646186
# 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
def index
rules = if params[:since].present?
@@ -52,20 +52,20 @@ module Api
private
def authenticate_project!
public_key = params[:public_key] || params[:project_id]
def authenticate_dsn!
@dsn = DsnAuthenticationService.authenticate(request)
@project = Project.find_by(public_key: public_key)
unless @project
render json: { error: "Invalid project key" }, status: :unauthorized
unless @dsn
render json: { error: "Invalid DSN key" }, status: :unauthorized
return
end
rescue DsnAuthenticationService::AuthenticationError => e
render json: { error: e.message }, status: :unauthorized
end
def check_project_enabled
unless @project.enabled?
render json: { error: "Project is disabled" }, status: :forbidden
def check_dsn_enabled
unless @dsn.enabled?
render json: { error: "DSN is disabled" }, status: :forbidden
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
class EventsController < ApplicationController
before_action :set_project
def index
@events = @project.events.order(timestamp: :desc)
Rails.logger.debug "Found project? #{@project.name} / #{@project.events.count} / #{@events.count}"
@events = Event.order(timestamp: :desc)
Rails.logger.debug "Found #{@events.count} total events"
Rails.logger.debug "Action: #{params[:waf_action]}"
# Apply filters
@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.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
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)
@@ -23,11 +22,4 @@ class EventsController < ApplicationController
Rails.logger.debug "Events count after pagination: #{@events.count}"
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
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

View File

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

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
layout "authentication"
allow_unauthenticated_access only: [:new, :create]
before_action :ensure_no_users_exist, only: [:new, :create]

View File

@@ -3,15 +3,14 @@
class RulesController < ApplicationController
# Follow proper before_action order:
# 1. Authentication/Authorization
allow_unauthenticated_access only: [:index, :show]
# All actions require authentication
# 2. Resource loading
before_action :set_rule, only: [:show, :edit, :update, :disable, :enable]
before_action :set_project, only: [:index, :show]
# GET /rules
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
@actions = Rule::ACTIONS
end
@@ -43,6 +42,9 @@ class RulesController < ApplicationController
@rule_types = Rule::RULE_TYPES
@actions = Rule::ACTIONS
# Process additional form data for quick create
process_quick_create_parameters
# Handle network range creation if CIDR is provided
if params[:cidr].present? && @rule.network_rule?
network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range|
@@ -53,8 +55,17 @@ class RulesController < ApplicationController
@rule.network_range = network_range
end
# Calculate priority automatically based on rule type
calculate_rule_priority
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
render :new, status: :unprocessable_entity
end
@@ -122,13 +133,236 @@ class RulesController < ApplicationController
params.require(:rule).permit(permitted)
end
def set_project
# For now, use the first project or create a default one
@project = Project.first || Project.create!(
name: 'Default Project',
slug: 'default',
public_key: SecureRandom.hex(32)
)
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
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
layout "authentication"
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." }