diff --git a/extensions/Servers/Proxmox/Livewire/Firewall.php b/extensions/Servers/Proxmox/Livewire/Firewall.php index 603edf3cd..add3232df 100644 --- a/extensions/Servers/Proxmox/Livewire/Firewall.php +++ b/extensions/Servers/Proxmox/Livewire/Firewall.php @@ -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([ diff --git a/extensions/Servers/Proxmox/Models/FirewallConfiguration.php b/extensions/Servers/Proxmox/Models/FirewallConfiguration.php new file mode 100644 index 000000000..a1178c00d --- /dev/null +++ b/extensions/Servers/Proxmox/Models/FirewallConfiguration.php @@ -0,0 +1,50 @@ + '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; + } +} diff --git a/extensions/Servers/Proxmox/database/migrations/2025_12_08_000000_create_ext_proxmox_firewall_configurations_table.php b/extensions/Servers/Proxmox/database/migrations/2025_12_08_000000_create_ext_proxmox_firewall_configurations_table.php new file mode 100644 index 000000000..c61c031f1 --- /dev/null +++ b/extensions/Servers/Proxmox/database/migrations/2025_12_08_000000_create_ext_proxmox_firewall_configurations_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/extensions/Servers/Proxmox/views/firewall.blade.php b/extensions/Servers/Proxmox/views/firewall.blade.php index 39fdc31c1..d210ad902 100644 --- a/extensions/Servers/Proxmox/views/firewall.blade.php +++ b/extensions/Servers/Proxmox/views/firewall.blade.php @@ -365,6 +365,16 @@
+ {{-- Tab Contents --}} @@ -1384,6 +1394,87 @@ @endif + + {{-- SAVED CONFIGURATIONS TAB --}} +
+
+
+

Saved Configurations

+

Save and reuse firewall configurations across your VPS instances

+
+ +
+ + @if(count($savedConfigurations) > 0) +
+ + + + + + + + + + + + @foreach($savedConfigurations as $config) + + + + + + + + @endforeach + +
Configuration NameRulesPoliciesCreatedActions
+
+
+ +
+ {{ $config['configuration_name'] }} +
+
+ + {{ is_array($config['firewall_rules']) ? count($config['firewall_rules']) : 0 }} rules + + +
+ + IN: {{ $config['incoming_policy'] }} + + + OUT: {{ $config['outgoing_policy'] }} + +
+
+ {{ \Carbon\Carbon::parse($config['created_at'])->format('M d, Y H:i') }} + +
+ + + +
+
+
+ @else +
+ +

No Saved Configurations

+

Save your current firewall configuration to reuse it later or apply it to other VPS instances

+
+ @endif +
@else @@ -1853,6 +1944,298 @@ @endif +{{-- Save Configuration Modal --}} +@if($showSaveConfigModal) +
+
+ +
+
+ +
+
+

Save Current Configuration

+

Save your current firewall rules and policies as a reusable configuration

+
+
+ +
+
+ + + @error('newConfigName') +

{{ $message }}

+ @enderror +
+ +
+

Configuration Summary

+
+
+ Rules + {{ count($rules) }} +
+
+ Incoming Policy + {{ $policyIn }} +
+
+ Outgoing Policy + {{ $policyOut }} +
+
+
+
+ +
+ + +
+
+
+@endif + +{{-- View Configuration Modal --}} +@if($showViewConfigModal && $viewingConfiguration) +
+
+
+ +
+
+ +
+
+

{{ $viewingConfiguration->configuration_name }}

+

Created on {{ $viewingConfiguration->created_at->format('M d, Y H:i') }}

+
+
+
+ +
+ {{-- Policies --}} +
+

Traffic Policies

+
+
+
+ Incoming Traffic + + {{ $viewingConfiguration->incoming_policy }} + +
+
+
+
+ Outgoing Traffic + + {{ $viewingConfiguration->outgoing_policy }} + +
+
+
+
+ + {{-- Compatibility Warnings --}} + @if(count($configCompatibilityIssues) > 0) +
+
+ +
+

Compatibility Issues

+

The following rules may not be compatible with this VPS:

+
    + @foreach($configCompatibilityIssues as $issue) +
  • • Rule #{{ $issue['rule_index'] + 1 }}: {{ implode(', ', $issue['issues']) }}
  • + @endforeach +
+
+
+
+ @endif + + {{-- Rules --}} +
+

Firewall Rules ({{ count($viewingConfiguration->firewall_rules) }})

+ @if(count($viewingConfiguration->firewall_rules) > 0) +
+ + + + + + + + + + + + + + + @foreach($viewingConfiguration->firewall_rules as $index => $rule) + @php + $hasIssue = collect($configCompatibilityIssues)->contains('rule_index', $index); + @endphp + + + + + + + + + + + @endforeach + +
#ActionDirectionSourceDestinationProtocolPortStatus
{{ $index + 1 }} + + {{ $rule['action'] ?? 'ACCEPT' }} + + {{ strtoupper($rule['type'] ?? '-') }}{{ $rule['source'] ?? 'any' }}{{ $rule['dest'] ?? 'any' }}{{ $rule['proto'] ?? '-' }}{{ $rule['dport'] ?? '-' }} + @if($hasIssue) + Incompatible + @elseif(isset($rule['enable']) && $rule['enable'] == 0) + Disabled + @else + Enabled + @endif +
+
+ @else +

No rules in this configuration

+ @endif +
+
+ +
+
+ + +
+
+
+
+@endif + +{{-- Apply Configuration Modal --}} +@if($showApplyConfigModal && $applyingConfiguration) +
+
+ +
+
+ +
+
+

Apply Configuration

+

Apply "{{ $applyingConfiguration->configuration_name }}" to this VPS

+
+
+ +
+
+ +
+

Warning

+

This will replace all existing firewall rules with the rules from this configuration. This action cannot be undone.

+
+
+
+ + @if(count($applyCompatibilityWarnings) > 0) +
+
+ +
+

Compatibility Notice

+

{{ count($applyCompatibilityWarnings) }} rule(s) in this configuration are not fully compatible with this VPS and will be disabled.

+
+
+
+ @endif + +
+

Configuration Details

+
+
+

Rules

+

{{ count($applyingConfiguration->firewall_rules) }}

+
+
+

IN Policy

+

{{ $applyingConfiguration->incoming_policy }}

+
+
+

OUT Policy

+

{{ $applyingConfiguration->outgoing_policy }}

+
+
+
+ +
+ + +
+
+
+@endif + +{{-- Delete Configuration Modal --}} +@if($showDeleteConfigModal && $deletingConfigId) +
+
+ +
+
+ +
+
+

Delete Configuration

+

Are you sure you want to delete this saved configuration? This action cannot be undone.

+
+
+
+ + +
+
+
+@endif + @script