Add saved firewall configuration functionality
- Implemented the ability to save, view, apply, and delete firewall configurations for users. - Created a new model for FirewallConfiguration and corresponding database migration. - Enhanced the firewall view to include a tab for managing saved configurations.
This commit is contained in:
@@ -4,8 +4,10 @@ namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Models\FirewallConfiguration;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Services\CoreRouterClient;
|
||||
@@ -47,6 +49,19 @@ class Firewall extends Component
|
||||
public bool $showDeleteRuleModal = false;
|
||||
public ?int $deletingRuleIndex = null;
|
||||
|
||||
// Saved Configurations
|
||||
public array $savedConfigurations = [];
|
||||
public bool $showSaveConfigModal = false;
|
||||
public string $newConfigName = '';
|
||||
public bool $showViewConfigModal = false;
|
||||
public ?FirewallConfiguration $viewingConfiguration = null;
|
||||
public array $configCompatibilityIssues = [];
|
||||
public bool $showDeleteConfigModal = false;
|
||||
public ?int $deletingConfigId = null;
|
||||
public bool $showApplyConfigModal = false;
|
||||
public ?FirewallConfiguration $applyingConfiguration = null;
|
||||
public array $applyCompatibilityWarnings = [];
|
||||
|
||||
// Available Proxmox Macros
|
||||
public array $availableMacros = [
|
||||
'' => 'None',
|
||||
@@ -256,6 +271,7 @@ class Firewall extends Component
|
||||
$this->loadFirewallRules();
|
||||
$this->loadAliases();
|
||||
$this->loadIPSets();
|
||||
$this->loadSavedConfigurations();
|
||||
// Don't load logs on mount - load lazily when logs tab is viewed
|
||||
}
|
||||
|
||||
@@ -1846,6 +1862,432 @@ class Firewall extends Component
|
||||
return round($bytes / pow(1024, $power), 2) . ' ' . $units[$power];
|
||||
}
|
||||
|
||||
// ==================== SAVED CONFIGURATIONS ====================
|
||||
|
||||
/**
|
||||
* Load all saved firewall configurations for the current user.
|
||||
*/
|
||||
protected function loadSavedConfigurations(): void
|
||||
{
|
||||
$userId = Auth::id();
|
||||
if (!$userId) {
|
||||
$this->savedConfigurations = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$this->savedConfigurations = FirewallConfiguration::forUser($userId)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the save configuration modal.
|
||||
*/
|
||||
public function openSaveConfigModal(): void
|
||||
{
|
||||
$this->newConfigName = '';
|
||||
$this->showSaveConfigModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the save configuration modal.
|
||||
*/
|
||||
public function closeSaveConfigModal(): void
|
||||
{
|
||||
$this->newConfigName = '';
|
||||
$this->showSaveConfigModal = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current firewall configuration.
|
||||
*/
|
||||
public function saveConfiguration(): void
|
||||
{
|
||||
$this->validate([
|
||||
'newConfigName' => 'required|string|min:1|max:255',
|
||||
]);
|
||||
|
||||
$userId = Auth::id();
|
||||
if (!$userId) {
|
||||
$this->error = 'You must be logged in to save configurations';
|
||||
$this->showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loading = true;
|
||||
|
||||
try {
|
||||
// Capture the current firewall state
|
||||
$configData = [
|
||||
'rules' => $this->rules,
|
||||
'policyIn' => $this->policyIn,
|
||||
'policyOut' => $this->policyOut,
|
||||
];
|
||||
|
||||
FirewallConfiguration::create([
|
||||
'user_id' => $userId,
|
||||
'configuration_name' => trim($this->newConfigName),
|
||||
'firewall_rules' => $configData['rules'],
|
||||
'incoming_policy' => $configData['policyIn'],
|
||||
'outgoing_policy' => $configData['policyOut'],
|
||||
]);
|
||||
|
||||
$this->notify('Configuration saved successfully', 'success');
|
||||
$this->closeSaveConfigModal();
|
||||
$this->loadSavedConfigurations();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to save firewall configuration: ' . $e->getMessage());
|
||||
$this->error = 'Failed to save configuration: ' . $e->getMessage();
|
||||
$this->showErrorModal = true;
|
||||
} finally {
|
||||
$this->loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the view configuration modal.
|
||||
*/
|
||||
public function viewConfiguration(int $configId): void
|
||||
{
|
||||
$userId = Auth::id();
|
||||
if (!$userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = FirewallConfiguration::forUser($userId)->find($configId);
|
||||
if (!$config) {
|
||||
$this->error = 'Configuration not found or access denied';
|
||||
$this->showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->viewingConfiguration = $config;
|
||||
$this->configCompatibilityIssues = $this->checkConfigurationCompatibility($config);
|
||||
$this->showViewConfigModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the view configuration modal.
|
||||
*/
|
||||
public function closeViewConfigModal(): void
|
||||
{
|
||||
$this->viewingConfiguration = null;
|
||||
$this->configCompatibilityIssues = [];
|
||||
$this->showViewConfigModal = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check configuration compatibility with the current VPS.
|
||||
*/
|
||||
protected function checkConfigurationCompatibility(FirewallConfiguration $config): array
|
||||
{
|
||||
$issues = [];
|
||||
$savedRules = $config->firewall_rules;
|
||||
|
||||
if (!is_array($savedRules)) {
|
||||
return $issues;
|
||||
}
|
||||
|
||||
// Get current available interfaces
|
||||
$availableInterfaces = collect($this->networkAdapters)->pluck('name')->toArray();
|
||||
|
||||
foreach ($savedRules as $index => $rule) {
|
||||
$ruleIssues = [];
|
||||
|
||||
// Check if interface exists on current VPS
|
||||
if (!empty($rule['iface']) && !in_array($rule['iface'], $availableInterfaces)) {
|
||||
$ruleIssues[] = "Interface '{$rule['iface']}' does not exist on this VPS";
|
||||
}
|
||||
|
||||
// Check if source IP is available (if it references a specific IP)
|
||||
if (!empty($rule['source'])) {
|
||||
$sourceIp = $rule['source'];
|
||||
// Skip validation for CIDR ranges, aliases, or "any"
|
||||
if (!str_contains($sourceIp, '/') &&
|
||||
!str_starts_with($sourceIp, '+') &&
|
||||
$sourceIp !== 'any' &&
|
||||
filter_var($sourceIp, FILTER_VALIDATE_IP)) {
|
||||
// This is a specific IP, check if it's one of our IPs
|
||||
$ourIps = collect($this->availableIps)->pluck('ip')->toArray();
|
||||
if (!in_array($sourceIp, $ourIps)) {
|
||||
// Not one of our IPs, which is fine for source (external IPs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if destination IP is available
|
||||
if (!empty($rule['dest'])) {
|
||||
$destIp = $rule['dest'];
|
||||
// Skip validation for CIDR ranges, aliases, or "any"
|
||||
if (!str_contains($destIp, '/') &&
|
||||
!str_starts_with($destIp, '+') &&
|
||||
$destIp !== 'any' &&
|
||||
filter_var($destIp, FILTER_VALIDATE_IP)) {
|
||||
// This is a specific IP, check if it's one of our IPs
|
||||
$ourIps = collect($this->availableIps)->pluck('ip')->toArray();
|
||||
if (!in_array($destIp, $ourIps)) {
|
||||
$ruleIssues[] = "Destination IP '{$destIp}' is not assigned to this VPS";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($ruleIssues)) {
|
||||
$issues[] = [
|
||||
'rule_index' => $index,
|
||||
'rule' => $rule,
|
||||
'issues' => $ruleIssues,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the confirm apply configuration modal.
|
||||
*/
|
||||
public function confirmApplyConfiguration(int $configId): void
|
||||
{
|
||||
$userId = Auth::id();
|
||||
if (!$userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = FirewallConfiguration::forUser($userId)->find($configId);
|
||||
if (!$config) {
|
||||
$this->error = 'Configuration not found or access denied';
|
||||
$this->showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyingConfiguration = $config;
|
||||
$this->applyCompatibilityWarnings = $this->checkConfigurationCompatibility($config);
|
||||
$this->showApplyConfigModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the apply configuration modal.
|
||||
*/
|
||||
public function closeApplyConfigModal(): void
|
||||
{
|
||||
$this->applyingConfiguration = null;
|
||||
$this->applyCompatibilityWarnings = [];
|
||||
$this->showApplyConfigModal = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a saved configuration to the current VPS.
|
||||
*/
|
||||
public function applyConfiguration(): void
|
||||
{
|
||||
if (!$this->applyingConfiguration) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = Auth::id();
|
||||
if (!$userId) {
|
||||
$this->error = 'You must be logged in to apply configurations';
|
||||
$this->showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if ($this->applyingConfiguration->user_id !== $userId) {
|
||||
$this->error = 'Access denied';
|
||||
$this->showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loading = true;
|
||||
$incompatibleRulesDisabled = 0;
|
||||
|
||||
try {
|
||||
$config = $this->applyingConfiguration;
|
||||
$savedRules = $config->firewall_rules;
|
||||
$availableInterfaces = collect($this->networkAdapters)->pluck('name')->toArray();
|
||||
$ourIps = collect($this->availableIps)->pluck('ip')->toArray();
|
||||
|
||||
// Step 1: Update policies
|
||||
$this->policyIn = $config->incoming_policy;
|
||||
$this->policyOut = $config->outgoing_policy;
|
||||
|
||||
$response = $this->firewallRequest(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options',
|
||||
'put',
|
||||
[
|
||||
'policy_in' => $this->policyIn,
|
||||
'policy_out' => $this->policyOut,
|
||||
'dhcp' => 0,
|
||||
'ndp' => 1,
|
||||
]
|
||||
);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new \Exception('Failed to update firewall policies');
|
||||
}
|
||||
|
||||
// Step 2: Delete all existing rules
|
||||
foreach (array_reverse($this->rules) as $rule) {
|
||||
if (isset($rule['pos'])) {
|
||||
$this->firewallRequest(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $rule['pos'],
|
||||
'delete'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create new rules from saved configuration
|
||||
if (is_array($savedRules)) {
|
||||
foreach ($savedRules as $savedRule) {
|
||||
$isCompatible = true;
|
||||
$rulePayload = [];
|
||||
|
||||
// Check interface compatibility
|
||||
if (!empty($savedRule['iface']) && !in_array($savedRule['iface'], $availableInterfaces)) {
|
||||
$isCompatible = false;
|
||||
}
|
||||
|
||||
// Check destination IP compatibility
|
||||
if (!empty($savedRule['dest'])) {
|
||||
$destIp = $savedRule['dest'];
|
||||
if (!str_contains($destIp, '/') &&
|
||||
!str_starts_with($destIp, '+') &&
|
||||
$destIp !== 'any' &&
|
||||
filter_var($destIp, FILTER_VALIDATE_IP) &&
|
||||
!in_array($destIp, $ourIps)) {
|
||||
$isCompatible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the rule payload
|
||||
$rulePayload['action'] = $savedRule['action'] ?? 'ACCEPT';
|
||||
$rulePayload['type'] = $savedRule['type'] ?? 'in';
|
||||
|
||||
// If incompatible, disable the rule
|
||||
if (!$isCompatible) {
|
||||
$rulePayload['enable'] = 0;
|
||||
$incompatibleRulesDisabled++;
|
||||
} else {
|
||||
$rulePayload['enable'] = isset($savedRule['enable']) ? (int)$savedRule['enable'] : 1;
|
||||
}
|
||||
|
||||
// Add optional fields
|
||||
if (!empty($savedRule['iface']) && in_array($savedRule['iface'], $availableInterfaces)) {
|
||||
$rulePayload['iface'] = $savedRule['iface'];
|
||||
}
|
||||
if (!empty($savedRule['source'])) {
|
||||
$rulePayload['source'] = $savedRule['source'];
|
||||
}
|
||||
if (!empty($savedRule['dest']) && $isCompatible) {
|
||||
$rulePayload['dest'] = $savedRule['dest'];
|
||||
}
|
||||
if (!empty($savedRule['proto'])) {
|
||||
$rulePayload['proto'] = $savedRule['proto'];
|
||||
}
|
||||
if (!empty($savedRule['dport'])) {
|
||||
$rulePayload['dport'] = $savedRule['dport'];
|
||||
}
|
||||
if (!empty($savedRule['sport'])) {
|
||||
$rulePayload['sport'] = $savedRule['sport'];
|
||||
}
|
||||
if (!empty($savedRule['comment'])) {
|
||||
$rulePayload['comment'] = $savedRule['comment'];
|
||||
}
|
||||
if (!empty($savedRule['log']) && $savedRule['log'] !== 'nolog') {
|
||||
$rulePayload['log'] = $savedRule['log'];
|
||||
}
|
||||
|
||||
// Create the rule
|
||||
$this->firewallRequest(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules',
|
||||
'post',
|
||||
$rulePayload
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload the current state
|
||||
$this->loadFirewallOptions();
|
||||
$this->loadFirewallRules();
|
||||
|
||||
$message = 'Configuration applied successfully';
|
||||
if ($incompatibleRulesDisabled > 0) {
|
||||
$message .= ". {$incompatibleRulesDisabled} rule(s) were disabled due to incompatibility with this VPS.";
|
||||
$this->notify($message, 'warning');
|
||||
} else {
|
||||
$this->notify($message, 'success');
|
||||
}
|
||||
|
||||
$this->closeApplyConfigModal();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to apply firewall configuration: ' . $e->getMessage());
|
||||
$this->error = 'Failed to apply configuration: ' . $e->getMessage();
|
||||
$this->showErrorModal = true;
|
||||
} finally {
|
||||
$this->loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm deletion of a saved configuration.
|
||||
*/
|
||||
public function confirmDeleteConfiguration(int $configId): void
|
||||
{
|
||||
$this->deletingConfigId = $configId;
|
||||
$this->showDeleteConfigModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel deletion of a saved configuration.
|
||||
*/
|
||||
public function cancelDeleteConfiguration(): void
|
||||
{
|
||||
$this->deletingConfigId = null;
|
||||
$this->showDeleteConfigModal = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved configuration.
|
||||
*/
|
||||
public function deleteConfiguration(): void
|
||||
{
|
||||
if (!$this->deletingConfigId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = Auth::id();
|
||||
if (!$userId) {
|
||||
$this->error = 'You must be logged in to delete configurations';
|
||||
$this->showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loading = true;
|
||||
|
||||
try {
|
||||
$config = FirewallConfiguration::forUser($userId)->find($this->deletingConfigId);
|
||||
|
||||
if (!$config) {
|
||||
$this->error = 'Configuration not found or access denied';
|
||||
$this->showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$config->delete();
|
||||
|
||||
$this->notify('Configuration deleted successfully', 'success');
|
||||
$this->cancelDeleteConfiguration();
|
||||
$this->loadSavedConfigurations();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to delete firewall configuration: ' . $e->getMessage());
|
||||
$this->error = 'Failed to delete configuration: ' . $e->getMessage();
|
||||
$this->showErrorModal = true;
|
||||
} finally {
|
||||
$this->loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('proxmox::firewall')->layoutData([
|
||||
|
||||
50
extensions/Servers/Proxmox/Models/FirewallConfiguration.php
Normal file
50
extensions/Servers/Proxmox/Models/FirewallConfiguration.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Paymenter\Extensions\Servers\Proxmox\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class FirewallConfiguration extends Model
|
||||
{
|
||||
protected $table = 'ext_proxmox_firewall_configurations';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'configuration_name',
|
||||
'firewall_rules',
|
||||
'incoming_policy',
|
||||
'outgoing_policy',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'firewall_rules' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns this firewall configuration.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include configurations for a specific user.
|
||||
*/
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rules count for display.
|
||||
*/
|
||||
public function getRulesCountAttribute(): int
|
||||
{
|
||||
return is_array($this->firewall_rules) ? count($this->firewall_rules) : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ext_proxmox_firewall_configurations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('configuration_name');
|
||||
$table->json('firewall_rules');
|
||||
$table->string('incoming_policy', 10)->default('DROP');
|
||||
$table->string('outgoing_policy', 10)->default('ACCEPT');
|
||||
$table->timestamps();
|
||||
|
||||
// Index for faster lookups by user
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ext_proxmox_firewall_configurations');
|
||||
}
|
||||
};
|
||||
@@ -365,6 +365,16 @@
|
||||
</div>
|
||||
<div x-show="activeTab === 'logs'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
||||
</button>
|
||||
<button @click="activeTab = 'saved'"
|
||||
class="px-6 py-4 text-sm font-semibold transition-all relative whitespace-nowrap"
|
||||
:class="activeTab === 'saved' ? 'text-primary' : 'text-base/60 hover:text-base'">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-ri-save-3-line class="size-4" />
|
||||
<span>Saved Configurations</span>
|
||||
<span class="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-xs font-bold">{{ count($savedConfigurations) }}</span>
|
||||
</div>
|
||||
<div x-show="activeTab === 'saved'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Tab Contents --}}
|
||||
@@ -1384,6 +1394,87 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- SAVED CONFIGURATIONS TAB --}}
|
||||
<div x-show="activeTab === 'saved'" x-transition>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Saved Configurations</h3>
|
||||
<p class="text-sm text-base/60 mt-1">Save and reuse firewall configurations across your VPS instances</p>
|
||||
</div>
|
||||
<button type="button" wire:click="openSaveConfigModal" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
||||
<x-ri-save-3-line class="size-4" />
|
||||
Save Current Configuration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if(count($savedConfigurations) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral/20">
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Configuration Name</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Rules</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Policies</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Created</th>
|
||||
<th class="text-center py-3 px-4 text-xs font-semibold text-base/70 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($savedConfigurations as $config)
|
||||
<tr class="border-b border-neutral/10 hover:bg-background-secondary/30 transition-colors">
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<x-ri-shield-star-line class="size-4 text-primary" />
|
||||
</div>
|
||||
<span class="font-medium">{{ $config['configuration_name'] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="px-2 py-1 bg-info/10 text-info rounded-full text-xs font-medium">
|
||||
{{ is_array($config['firewall_rules']) ? count($config['firewall_rules']) : 0 }} rules
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 text-xs rounded {{ $config['incoming_policy'] === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
||||
IN: {{ $config['incoming_policy'] }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 text-xs rounded {{ $config['outgoing_policy'] === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
||||
OUT: {{ $config['outgoing_policy'] }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-sm text-base/70">
|
||||
{{ \Carbon\Carbon::parse($config['created_at'])->format('M d, Y H:i') }}
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button type="button" wire:click="confirmApplyConfiguration({{ $config['id'] }})" class="p-2 text-success hover:bg-success/10 rounded-lg transition-colors" title="Apply Configuration">
|
||||
<x-ri-play-circle-line class="size-5" />
|
||||
</button>
|
||||
<button type="button" wire:click="viewConfiguration({{ $config['id'] }})" class="p-2 text-info hover:bg-info/10 rounded-lg transition-colors" title="View Details">
|
||||
<x-ri-eye-line class="size-5" />
|
||||
</button>
|
||||
<button type="button" wire:click="confirmDeleteConfiguration({{ $config['id'] }})" class="p-2 text-error hover:bg-error/10 rounded-lg transition-colors" title="Delete Configuration">
|
||||
<x-ri-delete-bin-line class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-12 text-base/60">
|
||||
<x-ri-save-3-line class="size-16 mx-auto mb-4 opacity-30" />
|
||||
<h3 class="text-lg font-semibold mb-2">No Saved Configurations</h3>
|
||||
<p class="text-sm">Save your current firewall configuration to reuse it later or apply it to other VPS instances</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@@ -1853,6 +1944,298 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Save Configuration Modal --}}
|
||||
@if($showSaveConfigModal)
|
||||
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||||
<button wire:click="closeSaveConfigModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<x-ri-save-3-line class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-1">Save Current Configuration</h3>
|
||||
<p class="text-sm text-base/70">Save your current firewall rules and policies as a reusable configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Configuration Name</label>
|
||||
<input type="text" wire:model="newConfigName" placeholder="e.g., Web Server Rules"
|
||||
class="w-full px-4 py-2 bg-background border border-neutral rounded-lg focus:border-primary focus:outline-none transition-colors">
|
||||
@error('newConfigName')
|
||||
<p class="text-error text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold mb-3">Configuration Summary</h4>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="flex items-center justify-between p-2 bg-background rounded">
|
||||
<span class="text-base/60">Rules</span>
|
||||
<span class="font-semibold">{{ count($rules) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 bg-background rounded">
|
||||
<span class="text-base/60">Incoming Policy</span>
|
||||
<span class="font-semibold {{ $policyIn === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyIn }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 bg-background rounded">
|
||||
<span class="text-base/60">Outgoing Policy</span>
|
||||
<span class="font-semibold {{ $policyOut === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyOut }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end mt-6 pt-4 border-t border-neutral/20">
|
||||
<button wire:click="closeSaveConfigModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="saveConfiguration" wire:loading.attr="disabled" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
||||
<span wire:loading.remove wire:target="saveConfiguration">Save Configuration</span>
|
||||
<span wire:loading wire:target="saveConfiguration">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- View Configuration Modal --}}
|
||||
@if($showViewConfigModal && $viewingConfiguration)
|
||||
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="p-6 border-b border-neutral/20">
|
||||
<button wire:click="closeViewConfigModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-info/10 flex items-center justify-center flex-shrink-0">
|
||||
<x-ri-eye-line class="size-6 text-info" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-1">{{ $viewingConfiguration->configuration_name }}</h3>
|
||||
<p class="text-sm text-base/70">Created on {{ $viewingConfiguration->created_at->format('M d, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
{{-- Policies --}}
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold mb-3">Traffic Policies</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-background rounded-lg border border-neutral/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base/60">Incoming Traffic</span>
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold {{ $viewingConfiguration->incoming_policy === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
||||
{{ $viewingConfiguration->incoming_policy }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-background rounded-lg border border-neutral/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base/60">Outgoing Traffic</span>
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold {{ $viewingConfiguration->outgoing_policy === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
||||
{{ $viewingConfiguration->outgoing_policy }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Compatibility Warnings --}}
|
||||
@if(count($configCompatibilityIssues) > 0)
|
||||
<div class="mb-6 p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-ri-error-warning-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-warning mb-2">Compatibility Issues</h4>
|
||||
<p class="text-xs text-warning/80 mb-2">The following rules may not be compatible with this VPS:</p>
|
||||
<ul class="text-xs text-warning/80 space-y-1">
|
||||
@foreach($configCompatibilityIssues as $issue)
|
||||
<li>• Rule #{{ $issue['rule_index'] + 1 }}: {{ implode(', ', $issue['issues']) }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Rules --}}
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3">Firewall Rules ({{ count($viewingConfiguration->firewall_rules) }})</h4>
|
||||
@if(count($viewingConfiguration->firewall_rules) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral/20">
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">#</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Action</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Direction</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Source</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Destination</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Protocol</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Port</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($viewingConfiguration->firewall_rules as $index => $rule)
|
||||
@php
|
||||
$hasIssue = collect($configCompatibilityIssues)->contains('rule_index', $index);
|
||||
@endphp
|
||||
<tr class="border-b border-neutral/10 {{ $hasIssue ? 'bg-warning/5' : '' }}">
|
||||
<td class="py-2 px-3 text-base/60">{{ $index + 1 }}</td>
|
||||
<td class="py-2 px-3">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium {{ ($rule['action'] ?? 'ACCEPT') === 'ACCEPT' ? 'bg-success/10 text-success' : 'bg-error/10 text-error' }}">
|
||||
{{ $rule['action'] ?? 'ACCEPT' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-3">{{ strtoupper($rule['type'] ?? '-') }}</td>
|
||||
<td class="py-2 px-3 font-mono text-xs">{{ $rule['source'] ?? 'any' }}</td>
|
||||
<td class="py-2 px-3 font-mono text-xs">{{ $rule['dest'] ?? 'any' }}</td>
|
||||
<td class="py-2 px-3">{{ $rule['proto'] ?? '-' }}</td>
|
||||
<td class="py-2 px-3 font-mono text-xs">{{ $rule['dport'] ?? '-' }}</td>
|
||||
<td class="py-2 px-3">
|
||||
@if($hasIssue)
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-warning/10 text-warning">Incompatible</span>
|
||||
@elseif(isset($rule['enable']) && $rule['enable'] == 0)
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-base/10 text-base/60">Disabled</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-success/10 text-success">Enabled</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-base/60 text-center py-4">No rules in this configuration</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-neutral/20 bg-background/50">
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button wire:click="closeViewConfigModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
||||
Close
|
||||
</button>
|
||||
<button wire:click="confirmApplyConfiguration({{ $viewingConfiguration->id }})" class="px-4 py-2 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-sm font-medium flex items-center gap-2">
|
||||
<x-ri-play-circle-line class="size-4" />
|
||||
Apply Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Apply Configuration Modal --}}
|
||||
@if($showApplyConfigModal && $applyingConfiguration)
|
||||
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||||
<button wire:click="closeApplyConfigModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="w-12 h-12 rounded-full bg-success/10 flex items-center justify-center flex-shrink-0">
|
||||
<x-ri-play-circle-line class="size-6 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-1">Apply Configuration</h3>
|
||||
<p class="text-sm text-base/70">Apply "{{ $applyingConfiguration->configuration_name }}" to this VPS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-warning/10 border border-warning/30 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-ri-error-warning-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold text-warning mb-1">Warning</p>
|
||||
<p class="text-warning/80">This will replace all existing firewall rules with the rules from this configuration. This action cannot be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($applyCompatibilityWarnings) > 0)
|
||||
<div class="bg-info/10 border border-info/30 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-ri-information-line class="size-5 text-info flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold text-info mb-1">Compatibility Notice</p>
|
||||
<p class="text-info/80">{{ count($applyCompatibilityWarnings) }} rule(s) in this configuration are not fully compatible with this VPS and will be disabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-semibold mb-3">Configuration Details</h4>
|
||||
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||
<div class="text-center p-2 bg-background rounded">
|
||||
<p class="text-xs text-base/60 mb-1">Rules</p>
|
||||
<p class="font-semibold">{{ count($applyingConfiguration->firewall_rules) }}</p>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-background rounded">
|
||||
<p class="text-xs text-base/60 mb-1">IN Policy</p>
|
||||
<p class="font-semibold {{ $applyingConfiguration->incoming_policy === 'DROP' ? 'text-error' : 'text-success' }}">{{ $applyingConfiguration->incoming_policy }}</p>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-background rounded">
|
||||
<p class="text-xs text-base/60 mb-1">OUT Policy</p>
|
||||
<p class="font-semibold {{ $applyingConfiguration->outgoing_policy === 'DROP' ? 'text-error' : 'text-success' }}">{{ $applyingConfiguration->outgoing_policy }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button wire:click="closeApplyConfigModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="applyConfiguration" wire:loading.attr="disabled" class="px-4 py-2 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-sm font-medium">
|
||||
<span wire:loading.remove wire:target="applyConfiguration">Apply Configuration</span>
|
||||
<span wire:loading wire:target="applyConfiguration">Applying...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Delete Configuration Modal --}}
|
||||
@if($showDeleteConfigModal && $deletingConfigId)
|
||||
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||||
<button wire:click="cancelDeleteConfiguration" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-error/10 flex items-center justify-center flex-shrink-0">
|
||||
<x-ri-delete-bin-line class="size-6 text-error" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-2">Delete Configuration</h3>
|
||||
<p class="text-sm text-base/70">Are you sure you want to delete this saved configuration? This action cannot be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button wire:click="cancelDeleteConfiguration" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="deleteConfiguration" wire:loading.attr="disabled"
|
||||
style="padding: 0.5rem 1rem; background-color: #ef4444; color: white; border-radius: 0.5rem; transition: all 0.2s; font-size: 0.875rem; font-weight: 500;"
|
||||
onmouseover="this.style.backgroundColor='#dc2626'"
|
||||
onmouseout="this.style.backgroundColor='#ef4444'">
|
||||
<span wire:loading.remove wire:target="deleteConfiguration">Delete</span>
|
||||
<span wire:loading wire:target="deleteConfiguration">Deleting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@script
|
||||
<script>
|
||||
// IP location helper functions
|
||||
|
||||
Reference in New Issue
Block a user