Add "Apply to All Servers" functionality in Firewall component and view

This commit is contained in:
2025-12-08 14:13:14 -05:00
parent 78d3d17c36
commit b01593eac2
2 changed files with 464 additions and 4 deletions

View File

@@ -62,6 +62,10 @@ class Firewall extends Component
public ?FirewallConfiguration $applyingConfiguration = null;
public array $applyCompatibilityWarnings = [];
// Apply to All Servers
public bool $showApplyToAllModal = false;
public array $applyToAllResults = [];
// Available Proxmox Macros
public array $availableMacros = [
'' => 'None',
@@ -2288,6 +2292,329 @@ class Firewall extends Component
}
}
// ==================== APPLY TO ALL SERVERS ====================
/**
* Get the current configuration as a normalized array for comparison.
*/
protected function getCurrentConfigurationData(): array
{
return [
'rules' => $this->rules,
'policyIn' => $this->policyIn,
'policyOut' => $this->policyOut,
];
}
/**
* Check if the current configuration matches a saved configuration.
*/
public function currentConfigMatchesSaved(): bool
{
$currentConfig = $this->getCurrentConfigurationData();
foreach ($this->savedConfigurations as $saved) {
if ($this->configurationsMatch($currentConfig, [
'rules' => $saved['firewall_rules'] ?? [],
'policyIn' => $saved['incoming_policy'] ?? 'DROP',
'policyOut' => $saved['outgoing_policy'] ?? 'ACCEPT',
])) {
return true;
}
}
return false;
}
/**
* Get the saved configuration that matches the current configuration, if any.
*/
public function getMatchingSavedConfiguration(): ?array
{
$currentConfig = $this->getCurrentConfigurationData();
foreach ($this->savedConfigurations as $saved) {
if ($this->configurationsMatch($currentConfig, [
'rules' => $saved['firewall_rules'] ?? [],
'policyIn' => $saved['incoming_policy'] ?? 'DROP',
'policyOut' => $saved['outgoing_policy'] ?? 'ACCEPT',
])) {
return $saved;
}
}
return null;
}
/**
* Compare two configurations to see if they match.
*/
protected function configurationsMatch(array $config1, array $config2): bool
{
// Compare policies
if ($config1['policyIn'] !== $config2['policyIn'] || $config1['policyOut'] !== $config2['policyOut']) {
return false;
}
// Compare rules count
$rules1 = $config1['rules'] ?? [];
$rules2 = $config2['rules'] ?? [];
if (count($rules1) !== count($rules2)) {
return false;
}
// Compare each rule (ignoring position)
$normalizedRules1 = $this->normalizeRulesForComparison($rules1);
$normalizedRules2 = $this->normalizeRulesForComparison($rules2);
return $normalizedRules1 === $normalizedRules2;
}
/**
* Normalize rules for comparison by removing position-specific data.
*/
protected function normalizeRulesForComparison(array $rules): string
{
$normalized = [];
foreach ($rules as $rule) {
$normalizedRule = [
'type' => $rule['type'] ?? '',
'action' => $rule['action'] ?? '',
'enable' => isset($rule['enable']) ? (int)$rule['enable'] : 1,
'source' => $rule['source'] ?? '',
'dest' => $rule['dest'] ?? '',
'proto' => $rule['proto'] ?? '',
'dport' => $rule['dport'] ?? '',
'sport' => $rule['sport'] ?? '',
'macro' => $rule['macro'] ?? '',
'iface' => $rule['iface'] ?? '',
'log' => $rule['log'] ?? 'nolog',
'comment' => $rule['comment'] ?? '',
];
ksort($normalizedRule);
$normalized[] = $normalizedRule;
}
// Sort rules by their content to ensure order doesn't matter
usort($normalized, function($a, $b) {
return json_encode($a) <=> json_encode($b);
});
return json_encode($normalized);
}
/**
* Get all other servers owned by the current user.
*/
public function getUserOtherServers(): array
{
$userId = Auth::id();
if (!$userId) {
return [];
}
return Server::where('user_id', $userId)
->where('id', '!=', $this->server->id)
->with(['node', 'primaryIpv4', 'primaryIpv6'])
->get()
->toArray();
}
/**
* Check if Apply to All should be enabled.
* Returns true if current config is saved AND user has other servers with different configs.
*/
public function canApplyToAll(): bool
{
// Must have a saved configuration that matches current
if (!$this->currentConfigMatchesSaved()) {
return false;
}
// Must have other servers
$otherServers = $this->getUserOtherServers();
if (count($otherServers) === 0) {
return false;
}
return true;
}
/**
* Open the Apply to All modal.
*/
public function openApplyToAllModal(): void
{
$this->applyToAllResults = [];
$this->showApplyToAllModal = true;
}
/**
* Close the Apply to All modal.
*/
public function closeApplyToAllModal(): void
{
$this->applyToAllResults = [];
$this->showApplyToAllModal = false;
}
/**
* Apply the current configuration to all other servers owned by the user.
*/
public function applyToAllServers(): void
{
$userId = Auth::id();
if (!$userId) {
$this->error = 'You must be logged in to apply configurations';
$this->showErrorModal = true;
return;
}
// Verify we have a saved configuration
$matchingConfig = $this->getMatchingSavedConfiguration();
if (!$matchingConfig) {
$this->error = 'Current configuration must be saved before applying to all servers';
$this->showErrorModal = true;
return;
}
$otherServers = $this->getUserOtherServers();
if (count($otherServers) === 0) {
$this->error = 'No other servers found to apply configuration to';
$this->showErrorModal = true;
return;
}
$this->loading = true;
$results = [];
try {
foreach ($otherServers as $serverData) {
$server = Server::with('node')->find($serverData['id']);
if (!$server || !$server->node) {
$results[] = [
'server_id' => $serverData['id'],
'hostname' => $serverData['hostname'] ?? 'Unknown',
'success' => false,
'message' => 'Server or node not found',
];
continue;
}
try {
// Apply configuration to this server
$this->applyConfigurationToServer($server, $matchingConfig);
$results[] = [
'server_id' => $server->id,
'hostname' => $server->hostname ?? 'VPS ' . $server->vm_id,
'success' => true,
'message' => 'Configuration applied successfully',
];
} catch (\Exception $e) {
Log::error("Failed to apply firewall config to server {$server->id}: " . $e->getMessage());
$results[] = [
'server_id' => $server->id,
'hostname' => $server->hostname ?? 'VPS ' . $server->vm_id,
'success' => false,
'message' => 'Failed: ' . $e->getMessage(),
];
}
}
$this->applyToAllResults = $results;
$successCount = collect($results)->where('success', true)->count();
$totalCount = count($results);
if ($successCount === $totalCount) {
$this->notify("Configuration applied to all {$successCount} servers successfully", 'success');
} elseif ($successCount > 0) {
$this->notify("Configuration applied to {$successCount} of {$totalCount} servers", 'warning');
} else {
$this->error = 'Failed to apply configuration to any servers';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
Log::error('Failed to apply configuration to all servers: ' . $e->getMessage());
$this->error = 'Failed to apply configuration: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
/**
* Apply a saved configuration to a specific server.
*/
protected function applyConfigurationToServer(Server $server, array $config): void
{
$savedRules = $config['firewall_rules'] ?? [];
$incomingPolicy = $config['incoming_policy'] ?? 'DROP';
$outgoingPolicy = $config['outgoing_policy'] ?? 'ACCEPT';
// Step 1: Update policies
$response = $this->firewallRequest(
'/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/options',
'put',
[
'policy_in' => $incomingPolicy,
'policy_out' => $outgoingPolicy,
'dhcp' => 0,
'ndp' => 1,
]
);
if (!$response->successful()) {
throw new \Exception('Failed to update firewall policies');
}
// Step 2: Get existing rules and delete them
$existingRulesResponse = $this->firewallRequest(
'/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules'
);
if ($existingRulesResponse->successful()) {
$existingRules = $existingRulesResponse->json('data') ?? [];
foreach (array_reverse($existingRules) as $rule) {
if (isset($rule['pos'])) {
$this->firewallRequest(
'/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules/' . $rule['pos'],
'delete'
);
}
}
}
// Step 3: Create new rules from saved configuration
if (is_array($savedRules)) {
foreach ($savedRules as $savedRule) {
$rulePayload = [
'type' => $savedRule['type'] ?? 'in',
'action' => $savedRule['action'] ?? 'ACCEPT',
'enable' => isset($savedRule['enable']) ? (int)$savedRule['enable'] : 1,
];
if (!empty($savedRule['source'])) $rulePayload['source'] = $savedRule['source'];
if (!empty($savedRule['dest'])) $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['macro'])) $rulePayload['macro'] = $savedRule['macro'];
if (!empty($savedRule['comment'])) $rulePayload['comment'] = $savedRule['comment'];
if (!empty($savedRule['log']) && $savedRule['log'] !== 'nolog') $rulePayload['log'] = $savedRule['log'];
// Skip iface as it may not exist on other servers
$this->firewallRequest(
'/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules',
'post',
$rulePayload
);
}
}
}
public function render()
{
return view('proxmox::firewall')->layoutData([

View File

@@ -1402,10 +1402,26 @@
<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 class="flex items-center gap-2">
{{-- Apply to All Button --}}
<button type="button"
wire:click="openApplyToAllModal"
@if(!$this->canApplyToAll()) disabled @endif
class="px-4 py-2 bg-success text-white rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed {{ $this->canApplyToAll() ? 'hover:bg-success/90' : '' }}"
title="{{ !$this->currentConfigMatchesSaved() ? 'Save the current configuration first' : (count($this->getUserOtherServers()) === 0 ? 'No other servers to apply to' : 'Apply to all your other servers') }}">
<x-ri-global-line class="size-4" />
Apply to All
</button>
{{-- Save Current Configuration Button --}}
<button type="button"
wire:click="openSaveConfigModal"
@if($this->currentConfigMatchesSaved()) disabled @endif
class="px-4 py-2 bg-primary text-white rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed {{ !$this->currentConfigMatchesSaved() ? 'hover:bg-primary/90' : '' }}"
title="{{ $this->currentConfigMatchesSaved() ? 'This configuration is already saved' : 'Save the current configuration' }}">
<x-ri-save-3-line class="size-4" />
Save Current Configuration
</button>
</div>
</div>
@if(count($savedConfigurations) > 0)
@@ -2236,6 +2252,123 @@
</div>
@endif
{{-- Apply to All Servers Modal --}}
@if($showApplyToAllModal)
<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-2xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
<button wire:click="closeApplyToAllModal" 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-global-line class="size-6 text-success" />
</div>
<div>
<h3 class="text-xl font-bold mb-2">Apply to All Servers</h3>
<p class="text-sm text-base/70">Apply the current firewall configuration to all your other VPS instances.</p>
</div>
</div>
@php $otherServers = $this->getUserOtherServers(); @endphp
@if(count($applyToAllResults) > 0)
{{-- Show Results --}}
<div class="mb-6">
<h4 class="text-sm font-semibold mb-3">Results</h4>
<div class="space-y-2 max-h-64 overflow-y-auto">
@foreach($applyToAllResults as $result)
<div class="flex items-center justify-between p-3 rounded-lg {{ $result['success'] ? 'bg-success/10 border border-success/20' : 'bg-error/10 border border-error/20' }}">
<div class="flex items-center gap-3">
@if($result['success'])
<x-ri-checkbox-circle-fill class="size-5 text-success" />
@else
<x-ri-close-circle-fill class="size-5 text-error" />
@endif
<span class="font-medium">{{ $result['hostname'] }}</span>
</div>
<span class="text-xs {{ $result['success'] ? 'text-success' : 'text-error' }}">{{ $result['message'] }}</span>
</div>
@endforeach
</div>
</div>
<div class="flex gap-3 justify-end">
<button wire:click="closeApplyToAllModal" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
Done
</button>
</div>
@else
{{-- Show Confirmation --}}
<div class="bg-warning/10 border border-warning/30 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<x-ri-alert-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
<div>
<h4 class="font-semibold text-warning mb-1">Warning</h4>
<p class="text-sm text-base/70">This will replace the firewall configuration on all your other servers. Existing rules will be deleted and replaced with the current configuration.</p>
</div>
</div>
</div>
@if(count($otherServers) > 0)
<div class="mb-6">
<h4 class="text-sm font-semibold mb-3">Servers to Update ({{ count($otherServers) }})</h4>
<div class="space-y-2 max-h-48 overflow-y-auto">
@foreach($otherServers as $otherServer)
<div class="flex items-center justify-between p-3 bg-background/50 border border-neutral/20 rounded-lg">
<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-server-line class="size-4 text-primary" />
</div>
<div>
<span class="font-medium">{{ $otherServer['hostname'] ?? 'VPS ' . $otherServer['vm_id'] }}</span>
<span class="text-xs text-base/50 ml-2">VM {{ $otherServer['vm_id'] }}</span>
</div>
</div>
</div>
@endforeach
</div>
</div>
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4 mb-6">
<h4 class="text-sm font-semibold mb-2">Configuration to Apply</h4>
<div class="grid grid-cols-3 gap-3 text-sm">
<div>
<span class="text-base/60">Rules:</span>
<span class="font-medium ml-1">{{ count($rules) }}</span>
</div>
<div>
<span class="text-base/60">Policy In:</span>
<span class="font-medium ml-1 {{ $policyIn === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyIn }}</span>
</div>
<div>
<span class="text-base/60">Policy Out:</span>
<span class="font-medium ml-1 {{ $policyOut === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyOut }}</span>
</div>
</div>
</div>
@else
<div class="text-center py-8 text-base/60">
<x-ri-server-line class="size-12 mx-auto mb-3 opacity-30" />
<p>No other servers found to apply the configuration to.</p>
</div>
@endif
<div class="flex gap-3 justify-end">
<button wire:click="closeApplyToAllModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
Cancel
</button>
@if(count($otherServers) > 0)
<button wire:click="applyToAllServers" 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 disabled:opacity-50">
<span wire:loading.remove wire:target="applyToAllServers">Apply to {{ count($otherServers) }} Server{{ count($otherServers) > 1 ? 's' : '' }}</span>
<span wire:loading wire:target="applyToAllServers">Applying...</span>
</button>
@endif
</div>
@endif
</div>
</div>
@endif
@script
<script>
// IP location helper functions