Migrate to Postgresql for better network handling. Add more user functionality.

This commit is contained in:
Dan Milne
2025-11-06 14:08:39 +11:00
parent 85252a1a07
commit fc567f0b91
69 changed files with 4266 additions and 952 deletions

View File

@@ -0,0 +1,372 @@
<% content_for :title, "Create New Rule" %>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Create New Rule</h1>
<p class="mt-2 text-gray-600">Create a WAF rule to allow, block, or rate limit traffic</p>
</div>
<div class="bg-white shadow rounded-lg">
<%= form_with(model: @rule, local: true, class: "space-y-6") do |form| %>
<% if @rule.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" fill="currentColor" viewBox="0 0 20 20">
<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(@rule.errors.count, "error") %> with your submission:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @rule.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Rule Type Selection -->
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Rule Configuration</h3>
</div>
<div class="px-6 py-4 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :rule_type,
options_for_select(@rule_types.map { |type| [type.humanize, type] }, @rule.rule_type),
{ prompt: "Select rule type" },
{ 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: "rule_type_select" } %>
<p class="mt-2 text-sm text-gray-500">Choose the type of rule you want to create</p>
</div>
<div>
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :action,
options_for_select(@actions.map { |action| [action.humanize, action] }, @rule.action),
{ prompt: "Select 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" } %>
<p class="mt-2 text-sm text-gray-500">What action to take when this rule matches</p>
</div>
</div>
<!-- Network Range Selection (shown for network rules) -->
<div id="network_range_section" class="hidden">
<%= form.label :network_range_id, "Network Range", class: "block text-sm font-medium text-gray-700 mb-2" %>
<!-- Selected Network Range Display -->
<div id="selected_network_display" class="hidden mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div class="flex justify-between items-center">
<div>
<h4 class="text-sm font-medium text-blue-800">Selected Network Range</h4>
<div id="selected_network_info" class="mt-1 text-sm text-blue-700"></div>
</div>
<button type="button" onclick="clearSelectedNetwork()" class="text-blue-600 hover:text-blue-800">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Network Selection Interface -->
<div id="network_selection_interface" class="space-y-4">
<!-- Search Input -->
<div>
<input type="text"
id="network_search"
placeholder="Search by CIDR, IP, company, or ASN..."
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<p class="mt-2 text-sm text-gray-500">Search existing network ranges or enter a CIDR/IP address below</p>
</div>
<!-- Quick Create Input -->
<div class="flex space-x-2">
<%= text_field_tag :new_cidr, params[:cidr],
placeholder: "e.g., 192.168.1.0/24 or 203.0.113.1",
class: "flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
id: "new_cidr_input" %>
<button type="button" onclick="quickCreateNetwork()"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500">
Create & Select
</button>
</div>
<!-- Search Results -->
<div id="network_search_results" class="hidden">
<div class="border rounded-md divide-y max-h-64 overflow-y-auto">
<!-- Results will be populated here -->
</div>
</div>
<!-- Hidden field to store selected network range ID -->
<%= form.hidden_field :network_range_id, id: "selected_network_range_id", value: @rule.network_range_id %>
</div>
</div>
<!-- Conditions (shown for non-network rules) -->
<div id="conditions_section" class="hidden">
<div>
<%= form.label :conditions, "Conditions", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :conditions, rows: 4,
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: '{"path_pattern": "/admin/*", "user_agent": "bot*"}' %>
<p class="mt-2 text-sm text-gray-500">JSON format with matching conditions</p>
</div>
</div>
<!-- Metadata -->
<div data-controller="json-validator" data-json-validator-valid-class="json-valid" data-json-validator-invalid-class="json-invalid" data-json-validator-valid-status-class="json-valid-status" data-json-validator-invalid-status-class="json-invalid-status">
<%= form.label :metadata, "Metadata", class: "block text-sm font-medium text-gray-700" %>
<div class="relative">
<%= form.text_area :metadata, 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: '{"reason": "Suspicious activity detected", "source": "manual"}',
data: { json_validator_target: "textarea", action: "input->json-validator#validate" } %>
<div class="mt-1 flex items-center justify-between">
<div data-json-validator-target="status" class="text-sm"></div>
<div class="flex space-x-2">
<button type="button"
data-action="click->json-validator#format"
class="text-xs text-gray-500 hover:text-gray-700 underline">
Format JSON
</button>
<button type="button"
data-action="click->json-validator#insertSample"
data-json-validator-json-sample='{"reason": "Block malicious ISP", "threat_type": "botnet", "confidence": "high", "source": "manual"}'
class="text-xs text-gray-500 hover:text-gray-700 underline">
Insert Sample
</button>
</div>
</div>
</div>
<p class="mt-2 text-sm text-gray-500">JSON format with additional metadata</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :source,
options_for_select(Rule::SOURCES.map { |source| [source.humanize, source] }, @rule.source || "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" } %>
<p class="mt-2 text-sm text-gray-500">How this rule was created</p>
</div>
<div>
<%= form.label :expires_at, "Expires At", 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-2 text-sm text-gray-500">Leave blank for permanent rule</p>
</div>
<div class="flex items-center pt-6">
<%= form.check_box :enabled, 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>
</div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<%= link_to "Cancel", rules_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" %>
<%= form.submit "Create Rule", 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>
<% end %>
</div>
</div>
<script>
let selectedNetworkData = null;
document.addEventListener('DOMContentLoaded', function() {
const ruleTypeSelect = document.getElementById('rule_type_select');
const networkSection = document.getElementById('network_range_section');
const conditionsSection = document.getElementById('conditions_section');
function toggleSections() {
if (ruleTypeSelect.value === 'network') {
networkSection.classList.remove('hidden');
conditionsSection.classList.add('hidden');
} else {
networkSection.classList.add('hidden');
conditionsSection.classList.remove('hidden');
}
}
ruleTypeSelect.addEventListener('change', toggleSections);
toggleSections(); // Initial state
// Pre-select network range if provided
<% if @rule.network_range.present? %>
// Show selected network display
const displayDiv = document.getElementById('selected_network_display');
const infoDiv = document.getElementById('selected_network_info');
const selectionInterface = document.getElementById('network_selection_interface');
let infoHtml = '<strong><%= @rule.network_range.network %></strong>';
<% if @rule.network_range.company.present? %>
infoHtml += ' - <%= @rule.network_range.company %>';
<% end %>
<% if @rule.network_range.asn_org.present? %>
infoHtml += ' (ASN: <%= @rule.network_range.asn_org %>)';
<% end %>
infoDiv.innerHTML = infoHtml;
displayDiv.classList.remove('hidden');
selectionInterface.classList.add('hidden');
<% end %>
// Pre-fill CIDR if provided
<% if params[:cidr].present? %>
if (ruleTypeSelect.value === 'network') {
document.getElementById('new_cidr_input').value = '<%= params[:cidr] %>';
}
<% end %>
// Set up search on Enter key
document.getElementById('network_search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
searchNetworkRanges();
}
});
// Set up quick create on Enter key
document.getElementById('new_cidr_input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
quickCreateNetwork();
}
});
});
function searchNetworkRanges() {
const query = document.getElementById('network_search').value.trim();
if (!query) return;
const resultsDiv = document.getElementById('network_search_results');
resultsDiv.innerHTML = '<div class="p-4 text-center text-gray-500">Searching...</div>';
resultsDiv.classList.remove('hidden');
fetch(`/network_ranges/search?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
if (data.length === 0) {
resultsDiv.innerHTML = '<div class="p-4 text-center text-gray-500">No network ranges found. Try creating a new one below.</div>';
return;
}
const html = data.map(network => `
<div class="p-3 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
onclick="selectNetworkRange(${network.id}, '${network.network}', '${network.company || ''}', '${network.asn_org || ''}')">
<div>
<div class="font-medium text-gray-900">${network.network}</div>
${network.company ? `<div class="text-sm text-gray-600">${network.company}</div>` : ''}
${network.asn_org ? `<div class="text-sm text-gray-500">ASN: ${network.asn} - ${network.asn_org}</div>` : ''}
${network.country ? `<div class="text-sm text-gray-400">Country: ${network.country}</div>` : ''}
</div>
<div class="text-xs text-gray-400">
${network.is_datacenter ? '<span class="bg-gray-100 px-2 py-1 rounded">DC</span>' : ''}
${network.is_vpn ? '<span class="bg-blue-100 px-2 py-1 rounded">VPN</span>' : ''}
${network.is_proxy ? '<span class="bg-red-100 px-2 py-1 rounded">Proxy</span>' : ''}
</div>
</div>
`).join('');
resultsDiv.innerHTML = html;
})
.catch(error => {
console.error('Search error:', error);
resultsDiv.innerHTML = '<div class="p-4 text-center text-red-500">Search failed. Please try again.</div>';
});
}
function selectNetworkRange(id, network, company, asnOrg) {
selectedNetworkData = { id, network, company, asnOrg };
// Update hidden field
document.getElementById('selected_network_range_id').value = id;
// Update display
const displayDiv = document.getElementById('selected_network_display');
const infoDiv = document.getElementById('selected_network_info');
let infoHtml = `<strong>${network}</strong>`;
if (company) infoHtml += ` - ${company}`;
if (asnOrg) infoHtml += ` (ASN: ${asnOrg})`;
infoDiv.innerHTML = infoHtml;
displayDiv.classList.remove('hidden');
// Hide the entire selection interface
document.getElementById('network_selection_interface').classList.add('hidden');
// Clear search results
document.getElementById('network_search_results').classList.add('hidden');
document.getElementById('network_search').value = '';
}
function clearSelectedNetwork() {
selectedNetworkData = null;
document.getElementById('selected_network_range_id').value = '';
document.getElementById('selected_network_display').classList.add('hidden');
// Show the selection interface again
document.getElementById('network_selection_interface').classList.remove('hidden');
}
function quickCreateNetwork() {
const cidr = document.getElementById('new_cidr_input').value.trim();
if (!cidr) {
alert('Please enter a CIDR or IP address');
return;
}
// Simple CIDR validation
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\/\d{1,3})?$/;
if (!cidrRegex.test(cidr)) {
alert('Invalid CIDR or IP address format');
return;
}
// Create network range via API
fetch('/network_ranges', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
network_range: {
network: cidr,
source: 'manual',
creation_reason: 'Created from rule form'
}
})
})
.then(response => response.json())
.then(data => {
if (data.id) {
selectNetworkRange(data.id, data.network, data.company, data.asn_org);
document.getElementById('new_cidr_input').value = '';
} else {
alert('Failed to create network range: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Create error:', error);
alert('Failed to create network range. Please try again.');
});
}
</script>