2785 lines
100 KiB
PHP
2785 lines
100 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
class Firewall extends Component
|
|
{
|
|
// Server & Core State
|
|
public bool $firewallEnabled = false;
|
|
public bool $loading = false;
|
|
public string $error = '';
|
|
public bool $showErrorModal = false;
|
|
|
|
// Network Adapters
|
|
public array $networkAdapters = [];
|
|
public array $adapterFirewallStates = [];
|
|
public array $adapterConfigs = []; // Store full network config strings
|
|
|
|
// Firewall Options (policies, filters)
|
|
public string $policyIn = 'DROP';
|
|
public string $policyOut = 'ACCEPT';
|
|
public bool $showPolicyInfoModal = false;
|
|
|
|
// Rules Management
|
|
public array $rules = [];
|
|
public bool $showRuleForm = false;
|
|
public ?int $editingRuleIndex = null;
|
|
public string $ruleAction = 'ACCEPT';
|
|
public string $ruleType = 'in';
|
|
public string $ruleSource = '';
|
|
public string $ruleDest = '';
|
|
public string $ruleProto = '';
|
|
public string $ruleDport = '';
|
|
public string $ruleSport = '';
|
|
public string $ruleComment = '';
|
|
public bool $ruleEnable = true;
|
|
public string $ruleMacro = '';
|
|
public string $ruleIface = '';
|
|
public string $ruleLog = 'nolog';
|
|
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 = [];
|
|
public bool $showRenameConfigModal = false;
|
|
public ?int $renamingConfigId = null;
|
|
public string $renameConfigName = '';
|
|
|
|
// Apply to All Servers
|
|
public bool $showApplyToAllModal = false;
|
|
public array $applyToAllResults = [];
|
|
|
|
// Available Proxmox Macros (constant to avoid serialization in every Livewire request)
|
|
protected const AVAILABLE_MACROS = [
|
|
'' => 'None',
|
|
'Amanda' => 'Amanda Backup',
|
|
'Auth' => 'Auth (identd)',
|
|
'BGP' => 'Border Gateway Protocol',
|
|
'BitTorrent' => 'BitTorrent traffic',
|
|
'BitTorrent32' => 'BitTorrent traffic (ports 32768:65535)',
|
|
'Ceph' => 'Ceph Storage Cluster',
|
|
'CVSup' => 'CVSup',
|
|
'DHCPfwd' => 'Forwarded DHCP traffic',
|
|
'DHCPv6fwd' => 'Forwarded DHCPv6 traffic',
|
|
'DNS' => 'Domain Name System',
|
|
'Distcc' => 'Distcc',
|
|
'FTP' => 'File Transfer Protocol',
|
|
'Git' => 'Git',
|
|
'HKP' => 'OpenPGP HTTP Keyserver',
|
|
'HTTP' => 'Hypertext Transfer Protocol',
|
|
'HTTPS' => 'Hypertext Transfer Protocol (SSL)',
|
|
'ICPV2' => 'Internet Cache Protocol V2',
|
|
'IMAP' => 'Internet Message Access Protocol',
|
|
'IMAPS' => 'Internet Message Access Protocol (SSL)',
|
|
'IPsec' => 'IPsec',
|
|
'IPsecah' => 'IPsec AH',
|
|
'IPsecnat' => 'IPsec NAT-T',
|
|
'IRC' => 'Internet Relay Chat',
|
|
'Jetdirect' => 'HP Jetdirect',
|
|
'L2TP' => 'Layer 2 Tunneling Protocol',
|
|
'LDAP' => 'Lightweight Directory Access Protocol',
|
|
'LDAPS' => 'Lightweight Directory Access Protocol (SSL)',
|
|
'MSNP' => 'Microsoft Notification Protocol',
|
|
'MSSQL' => 'Microsoft SQL Server',
|
|
'Mail' => 'Mail traffic (SMTP, SMTPS, Submission)',
|
|
'NFS' => 'Network File System',
|
|
'NNTP' => 'NNTP',
|
|
'NNTPS' => 'NNTPS',
|
|
'NTP' => 'Network Time Protocol',
|
|
'OSPF' => 'OSPF',
|
|
'OpenVPN' => 'OpenVPN',
|
|
'PCA' => 'Symantec PCAnywhere',
|
|
'PMG' => 'Proxmox Mail Gateway',
|
|
'POP3' => 'POP3',
|
|
'POP3S' => 'POP3S',
|
|
'PPtP' => 'Point-to-Point Tunneling Protocol',
|
|
'Ping' => 'ICMP echo request',
|
|
'PostgreSQL' => 'PostgreSQL',
|
|
'Printer' => 'Line Printer Daemon',
|
|
'RDP' => 'Microsoft Remote Desktop',
|
|
'RIP' => 'Routing Information Protocol',
|
|
'RNDC' => 'BIND remote management',
|
|
'Razor' => 'Razor',
|
|
'Rsyncd' => 'Rsync daemon',
|
|
'SANE' => 'SANE network scanner',
|
|
'SMB' => 'Microsoft SMB',
|
|
'SMTP' => 'Simple Mail Transfer Protocol',
|
|
'SMTPS' => 'Simple Mail Transfer Protocol (SSL)',
|
|
'SNMP' => 'Simple Network Management Protocol',
|
|
'SPAMD' => 'SpamAssassin daemon traffic',
|
|
'SSH' => 'Secure Shell',
|
|
'SVN' => 'Apache Subversion',
|
|
'SixXS' => 'SixXS IPv6 Deployment',
|
|
'SpiceProxy' => 'Proxmox VE SPICE Display Proxy',
|
|
'Squid' => 'Squid web proxy',
|
|
'Submission' => 'Mail message submission',
|
|
'Syslog' => 'Syslog protocol',
|
|
'TFTP' => 'Trivial File Transfer Protocol',
|
|
'Telnet' => 'Telnet',
|
|
'Telnets' => 'Telnet (SSL)',
|
|
'Time' => 'RFC 868 Time',
|
|
'Trcrt' => 'Traceroute',
|
|
'VNC' => 'VNC traffic',
|
|
'VNCL' => 'VNC Latin1',
|
|
'Web' => 'WWW traffic (HTTP and HTTPS)',
|
|
'Webcache' => 'Web Cache/Proxy',
|
|
'Webmin' => 'Webmin',
|
|
'Whois' => 'Whois'
|
|
];
|
|
|
|
// Macro Definitions with ports and protocols (as per Proxmox)
|
|
protected const MACRO_DEFINITIONS = [
|
|
'Amanda' => ['proto' => 'tcp', 'dport' => '10080,10081,10082,10083'],
|
|
'Auth' => ['proto' => 'tcp', 'dport' => '113'],
|
|
'BGP' => ['proto' => 'tcp', 'dport' => '179'],
|
|
'BitTorrent' => ['proto' => 'tcp', 'dport' => '6881:6889'],
|
|
'BitTorrent32' => ['proto' => 'tcp', 'dport' => '32768:65535'],
|
|
'Ceph' => ['proto' => 'tcp', 'dport' => '6789,6800:7300'],
|
|
'CVSup' => ['proto' => 'tcp', 'dport' => '5999'],
|
|
'DHCPfwd' => ['proto' => 'udp', 'dport' => '67:68'],
|
|
'DHCPv6fwd' => ['proto' => 'udp', 'dport' => '546:547'],
|
|
'DNS' => ['proto' => 'udp', 'dport' => '53'],
|
|
'Distcc' => ['proto' => 'tcp', 'dport' => '3632'],
|
|
'FTP' => ['proto' => 'tcp', 'dport' => '21'],
|
|
'Git' => ['proto' => 'tcp', 'dport' => '9418'],
|
|
'HKP' => ['proto' => 'tcp', 'dport' => '11371'],
|
|
'HTTP' => ['proto' => 'tcp', 'dport' => '80'],
|
|
'HTTPS' => ['proto' => 'tcp', 'dport' => '443'],
|
|
'ICPV2' => ['proto' => 'udp', 'dport' => '3130'],
|
|
'IMAP' => ['proto' => 'tcp', 'dport' => '143'],
|
|
'IMAPS' => ['proto' => 'tcp', 'dport' => '993'],
|
|
'IPsec' => ['proto' => 'udp', 'dport' => '500'],
|
|
'IPsecah' => ['proto' => 'ah', 'dport' => ''],
|
|
'IPsecnat' => ['proto' => 'udp', 'dport' => '4500'],
|
|
'IRC' => ['proto' => 'tcp', 'dport' => '6667'],
|
|
'Jetdirect' => ['proto' => 'tcp', 'dport' => '9100'],
|
|
'L2TP' => ['proto' => 'udp', 'dport' => '1701'],
|
|
'LDAP' => ['proto' => 'tcp', 'dport' => '389'],
|
|
'LDAPS' => ['proto' => 'tcp', 'dport' => '636'],
|
|
'MSNP' => ['proto' => 'tcp', 'dport' => '1863'],
|
|
'MSSQL' => ['proto' => 'tcp', 'dport' => '1433'],
|
|
'Mail' => ['proto' => 'tcp', 'dport' => '25,465,587'],
|
|
'NFS' => ['proto' => 'tcp', 'dport' => '111,2049'],
|
|
'NNTP' => ['proto' => 'tcp', 'dport' => '119'],
|
|
'NNTPS' => ['proto' => 'tcp', 'dport' => '563'],
|
|
'NTP' => ['proto' => 'udp', 'dport' => '123'],
|
|
'OSPF' => ['proto' => 'ospf', 'dport' => ''],
|
|
'OpenVPN' => ['proto' => 'udp', 'dport' => '1194'],
|
|
'PCA' => ['proto' => 'tcp', 'dport' => '5631'],
|
|
'PMG' => ['proto' => 'tcp', 'dport' => '26'],
|
|
'POP3' => ['proto' => 'tcp', 'dport' => '110'],
|
|
'POP3S' => ['proto' => 'tcp', 'dport' => '995'],
|
|
'PPtP' => ['proto' => 'tcp', 'dport' => '1723'],
|
|
'Ping' => ['proto' => 'icmp', 'dport' => ''],
|
|
'PostgreSQL' => ['proto' => 'tcp', 'dport' => '5432'],
|
|
'Printer' => ['proto' => 'tcp', 'dport' => '515'],
|
|
'RDP' => ['proto' => 'tcp', 'dport' => '3389'],
|
|
'RIP' => ['proto' => 'udp', 'dport' => '520'],
|
|
'RNDC' => ['proto' => 'tcp', 'dport' => '953'],
|
|
'Razor' => ['proto' => 'tcp', 'dport' => '2703'],
|
|
'Rsyncd' => ['proto' => 'tcp', 'dport' => '873'],
|
|
'SANE' => ['proto' => 'tcp', 'dport' => '6566'],
|
|
'SMB' => ['proto' => 'tcp', 'dport' => '445'],
|
|
'SMTP' => ['proto' => 'tcp', 'dport' => '25'],
|
|
'SMTPS' => ['proto' => 'tcp', 'dport' => '465'],
|
|
'SNMP' => ['proto' => 'udp', 'dport' => '161,162'],
|
|
'SPAMD' => ['proto' => 'tcp', 'dport' => '783'],
|
|
'SSH' => ['proto' => 'tcp', 'dport' => '22'],
|
|
'SVN' => ['proto' => 'tcp', 'dport' => '3690'],
|
|
'SixXS' => ['proto' => 'tcp', 'dport' => '3874'],
|
|
'SpiceProxy' => ['proto' => 'tcp', 'dport' => '3128'],
|
|
'Squid' => ['proto' => 'tcp', 'dport' => '3128'],
|
|
'Submission' => ['proto' => 'tcp', 'dport' => '587'],
|
|
'Syslog' => ['proto' => 'udp', 'dport' => '514'],
|
|
'TFTP' => ['proto' => 'udp', 'dport' => '69'],
|
|
'Telnet' => ['proto' => 'tcp', 'dport' => '23'],
|
|
'Telnets' => ['proto' => 'tcp', 'dport' => '992'],
|
|
'Time' => ['proto' => 'tcp', 'dport' => '37'],
|
|
'Trcrt' => ['proto' => 'udp', 'dport' => '33434:33524'],
|
|
'VNC' => ['proto' => 'tcp', 'dport' => '5900:5909'],
|
|
'VNCL' => ['proto' => 'tcp', 'dport' => '5500:5509'],
|
|
'Web' => ['proto' => 'tcp', 'dport' => '80,443'],
|
|
'Webcache' => ['proto' => 'tcp', 'dport' => '3128,8080'],
|
|
'Webmin' => ['proto' => 'tcp', 'dport' => '10000'],
|
|
'Whois' => ['proto' => 'tcp', 'dport' => '43'],
|
|
];
|
|
|
|
// IP Aliases
|
|
public array $aliases = [];
|
|
public bool $showAliasForm = false;
|
|
public ?string $editingAliasName = null;
|
|
public string $aliasName = '';
|
|
public string $aliasCidr = '';
|
|
public string $aliasComment = '';
|
|
public bool $showDeleteAliasModal = false;
|
|
public ?string $deletingAliasName = null;
|
|
|
|
// IPSets
|
|
public array $ipsets = [];
|
|
public bool $showIPSetForm = false;
|
|
public ?string $editingIPSetName = null;
|
|
public string $ipsetName = '';
|
|
public string $ipsetComment = '';
|
|
public bool $showDeleteIPSetModal = false;
|
|
public ?string $deletingIPSetName = null;
|
|
|
|
// IPSet Members
|
|
public array $ipsetMembers = [];
|
|
public ?string $selectedIPSetName = null;
|
|
public bool $showIPSetMemberForm = false;
|
|
public string $memberCidr = '';
|
|
public string $memberComment = '';
|
|
public bool $memberNomatch = false;
|
|
public bool $showDeleteIPSetMemberModal = false;
|
|
public ?string $deletingMemberCidr = null;
|
|
|
|
// Firewall Logs
|
|
public array $firewallLogs = [];
|
|
public int $logLimit = 500; // Reduced to prevent 413 errors
|
|
public string $logError = '';
|
|
public bool $logsLoaded = false; // Track if logs have been loaded
|
|
|
|
// Pagination for logs
|
|
public int $currentPage = 1;
|
|
public int $perPage = 50;
|
|
public ?string $lastLogRefresh = null;
|
|
|
|
// Core Router integration for network analysis
|
|
public bool $coreRouterConfigured = false;
|
|
|
|
// Available IPs for rules
|
|
public array $availableIps = [];
|
|
|
|
public function mount(Server $server): void
|
|
{
|
|
parent::mount($server);
|
|
$this->server->loadMissing('ipAddresses');
|
|
|
|
$this->coreRouterConfigured = $this->coreRouterEnabled();
|
|
$this->loadAvailableIps();
|
|
$this->loadNetworkAdapters();
|
|
$this->loadFirewallOptions();
|
|
$this->loadFirewallRules();
|
|
$this->loadAliases();
|
|
$this->loadIPSets();
|
|
$this->loadSavedConfigurations();
|
|
// Don't load logs on mount - load lazily when logs tab is viewed
|
|
}
|
|
|
|
// ==================== AVAILABLE IPS ====================
|
|
|
|
protected function loadAvailableIps(): void
|
|
{
|
|
$allIps = collect();
|
|
|
|
if ($this->server->primaryIpv4) {
|
|
$allIps->push([
|
|
'ip' => $this->server->primaryIpv4->ip,
|
|
'type' => 'IPv4',
|
|
'label' => 'Primary IPv4'
|
|
]);
|
|
}
|
|
|
|
if ($this->server->primaryIpv6) {
|
|
$allIps->push([
|
|
'ip' => $this->server->primaryIpv6->ip,
|
|
'type' => 'IPv6',
|
|
'label' => 'Primary IPv6'
|
|
]);
|
|
}
|
|
|
|
foreach ($this->server->ipAddresses as $ipAddress) {
|
|
$allIps->push([
|
|
'ip' => $ipAddress->ip,
|
|
'type' => strtoupper($ipAddress->type),
|
|
'label' => 'Additional'
|
|
]);
|
|
}
|
|
|
|
$this->availableIps = $allIps->unique('ip')->values()->toArray();
|
|
}
|
|
|
|
// ==================== FIREWALL API REQUEST ====================
|
|
|
|
protected function firewallRequest(string $url, string $method = 'get', array $data = []): \Illuminate\Http\Client\Response
|
|
{
|
|
// Load config from the Server model directly (avoids debug_backtrace() class resolution issue)
|
|
$serverModel = \App\Models\Server::where('extension', 'Proxmox')->first();
|
|
$config = $serverModel ? $serverModel->settings->pluck('value', 'key')->toArray() : [];
|
|
$proxmox = new \Paymenter\Extensions\Servers\Proxmox\Proxmox($config);
|
|
$baseUrl = rtrim($proxmox->config('host'), '/');
|
|
$authToken = 'PVEAPIToken=' . $proxmox->config('user') . '=' . $proxmox->config('token');
|
|
$verifySsl = $proxmox->config('verify_ssl') ? true : false;
|
|
|
|
$fullUrl = $baseUrl . '/api2/json' . $url;
|
|
|
|
$acceptHeader = str_contains($url, '/firewall/log')
|
|
? 'application/json, text/plain, */*'
|
|
: 'application/json';
|
|
|
|
$headers = [
|
|
'Authorization' => $authToken,
|
|
'Accept' => $acceptHeader,
|
|
];
|
|
|
|
// Prevent caching for log requests
|
|
if (str_contains($url, '/firewall/log')) {
|
|
$headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
|
|
$headers['Pragma'] = 'no-cache';
|
|
}
|
|
|
|
$request = Http::withHeaders($headers)->withOptions(['verify' => $verifySsl])->asForm();
|
|
|
|
$response = match($method) {
|
|
'post' => $request->post($fullUrl, $data),
|
|
'put' => $request->put($fullUrl, $data),
|
|
'delete' => $request->delete($fullUrl),
|
|
default => Http::withHeaders($headers)->withOptions(['verify' => $verifySsl])->get($fullUrl, $data),
|
|
};
|
|
|
|
return $response;
|
|
}
|
|
|
|
// ==================== NETWORK ADAPTERS ====================
|
|
|
|
protected function loadNetworkAdapters(): void
|
|
{
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/config'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$config = $response->json()['data'] ?? [];
|
|
$adapters = [];
|
|
|
|
foreach ($config as $key => $value) {
|
|
if (preg_match('/^net(\d+)$/', $key, $matches)) {
|
|
$parts = explode(',', $value);
|
|
$model = explode('=', $parts[0])[0] ?? 'unknown';
|
|
$mac = null;
|
|
|
|
// Store the full configuration string for later modification
|
|
$this->adapterConfigs['net' . $matches[1]] = $value;
|
|
|
|
foreach ($parts as $part) {
|
|
if (str_contains($part, '=')) {
|
|
[$k, $v] = explode('=', $part, 2);
|
|
if (trim($k) === 'firewall') {
|
|
$this->adapterFirewallStates['net' . $matches[1]] = (int)$v === 1;
|
|
} elseif (trim($k) === 'macaddr') {
|
|
$mac = $v;
|
|
}
|
|
}
|
|
}
|
|
|
|
$adapters[] = [
|
|
'name' => 'net' . $matches[1],
|
|
'model' => $model,
|
|
'mac' => $mac,
|
|
'enabled' => $this->adapterFirewallStates['net' . $matches[1]] ?? false
|
|
];
|
|
}
|
|
}
|
|
|
|
$this->networkAdapters = $adapters;
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to load network adapters: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function toggleAdapterFirewall(string $adapter): void
|
|
{
|
|
// Validate adapter name format to prevent arbitrary config key injection
|
|
if (!preg_match('/^net\d+$/', $adapter)) {
|
|
$this->error = 'Invalid network adapter name';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$currentState = $this->adapterFirewallStates[$adapter] ?? false;
|
|
$newState = !$currentState;
|
|
|
|
// Get the full network configuration string
|
|
$configString = $this->adapterConfigs[$adapter] ?? '';
|
|
|
|
if (empty($configString)) {
|
|
$this->error = 'Network adapter configuration not found';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
// Parse the configuration string and modify the firewall parameter
|
|
$parts = explode(',', $configString);
|
|
$firewallFound = false;
|
|
|
|
foreach ($parts as $key => $part) {
|
|
$part = trim($part);
|
|
if (str_contains($part, '=')) {
|
|
[$k, $v] = explode('=', $part, 2);
|
|
if (trim($k) === 'firewall') {
|
|
$parts[$key] = 'firewall=' . ($newState ? '1' : '0');
|
|
$firewallFound = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If firewall parameter doesn't exist, add it
|
|
if (!$firewallFound) {
|
|
$parts[] = 'firewall=' . ($newState ? '1' : '0');
|
|
}
|
|
|
|
// Reconstruct the configuration string
|
|
$newConfigString = implode(',', $parts);
|
|
|
|
// Send the full modified configuration back to Proxmox
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/config',
|
|
'put',
|
|
[$adapter => $newConfigString]
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->adapterFirewallStates[$adapter] = $newState;
|
|
$this->adapterConfigs[$adapter] = $newConfigString; // Update stored config
|
|
$this->notify('Adapter firewall ' . ($newState ? 'enabled' : 'disabled'), 'success');
|
|
} else {
|
|
$this->error = 'Failed to toggle adapter firewall: ' . $response->body();
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to toggle adapter firewall: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
// ==================== FIREWALL OPTIONS ====================
|
|
|
|
protected function loadFirewallOptions(): void
|
|
{
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/options'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$options = $response->json()['data'] ?? [];
|
|
$this->firewallEnabled = ($options['enable'] ?? 0) == 1;
|
|
$this->policyIn = strtoupper($options['policy_in'] ?? 'DROP');
|
|
$this->policyOut = strtoupper($options['policy_out'] ?? 'ACCEPT');
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to load firewall options: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function toggleFirewall(): void
|
|
{
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$newState = !$this->firewallEnabled;
|
|
|
|
// Always force DHCP disabled and NDP enabled
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/options',
|
|
'put',
|
|
[
|
|
'enable' => $newState ? 1 : 0,
|
|
'dhcp' => 0, // Always disabled
|
|
'ndp' => 1, // Always enabled (allows router advertisements)
|
|
]
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->firewallEnabled = $newState;
|
|
$this->notify('Firewall ' . ($newState ? 'enabled' : 'disabled'), 'success');
|
|
} else {
|
|
// Log detailed error info for debugging
|
|
Log::error('Firewall toggle failed', [
|
|
'status' => $response->status(),
|
|
'body' => substr($response->body(), 0, 500),
|
|
'url' => '/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/options',
|
|
'headers' => $response->headers(),
|
|
]);
|
|
|
|
$errorBody = $response->json();
|
|
$errorMsg = $errorBody['errors'] ?? $response->body();
|
|
$this->error = 'Failed to toggle firewall (Status ' . $response->status() . '): ' . (is_array($errorMsg) ? json_encode($errorMsg) : substr((string)$errorMsg, 0, 200));
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to toggle firewall: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
public function updatePolicies(): void
|
|
{
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$allowedPolicies = ['ACCEPT', 'DROP', 'REJECT'];
|
|
if (!in_array($this->policyIn, $allowedPolicies, true)) {
|
|
$this->error = 'Invalid inbound policy. Must be one of: ' . implode(', ', $allowedPolicies);
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
if (!in_array($this->policyOut, $allowedPolicies, true)) {
|
|
$this->error = 'Invalid outbound policy. Must be one of: ' . implode(', ', $allowedPolicies);
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/options',
|
|
'put',
|
|
[
|
|
'policy_in' => $this->policyIn,
|
|
'policy_out' => $this->policyOut,
|
|
'dhcp' => 0, // Always disabled
|
|
'ndp' => 1, // Always enabled (allows router advertisements)
|
|
]
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('Firewall policies updated successfully', 'success');
|
|
$this->loadFirewallOptions();
|
|
} else {
|
|
$this->error = 'Failed to update firewall policies';
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to update policies: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
public function openPolicyInfoModal(): void
|
|
{
|
|
$this->showPolicyInfoModal = true;
|
|
}
|
|
|
|
public function closePolicyInfoModal(): void
|
|
{
|
|
$this->showPolicyInfoModal = false;
|
|
}
|
|
|
|
// ==================== RULES ====================
|
|
|
|
/**
|
|
* Get available macros for frontend use (avoids serializing large static data on every request)
|
|
*/
|
|
public function getAvailableMacrosProperty(): array
|
|
{
|
|
return self::AVAILABLE_MACROS;
|
|
}
|
|
|
|
/**
|
|
* Get macro definitions for frontend use
|
|
*/
|
|
public function getMacroDefinitionsProperty(): array
|
|
{
|
|
return self::MACRO_DEFINITIONS;
|
|
}
|
|
|
|
/**
|
|
* Get details for a specific macro
|
|
*/
|
|
public function getMacroDetails(string $macro): ?array
|
|
{
|
|
return self::MACRO_DEFINITIONS[$macro] ?? null;
|
|
}
|
|
|
|
protected function loadFirewallRules(): void
|
|
{
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/rules'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->rules = $response->json()['data'] ?? [];
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to load firewall rules: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function startCreateRule(): void
|
|
{
|
|
$this->resetRuleForm();
|
|
|
|
// Set default destination to first IP
|
|
if (!empty($this->availableIps)) {
|
|
$this->ruleDest = $this->availableIps[0]['ip'];
|
|
}
|
|
|
|
// Set default interface to first adapter
|
|
if (!empty($this->networkAdapters)) {
|
|
$this->ruleIface = $this->networkAdapters[0]['name'];
|
|
}
|
|
|
|
$this->editingRuleIndex = null;
|
|
$this->showRuleForm = true;
|
|
}
|
|
|
|
public function startEditRule(int $index): void
|
|
{
|
|
if (!isset($this->rules[$index])) {
|
|
$this->error = 'Rule not found';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$rule = $this->rules[$index];
|
|
$this->editingRuleIndex = $index;
|
|
$this->showRuleForm = true;
|
|
|
|
$this->ruleAction = $rule['action'] ?? 'ACCEPT';
|
|
$this->ruleType = $rule['type'] ?? 'in';
|
|
$this->ruleSource = $rule['source'] ?? '';
|
|
$this->ruleDest = $rule['dest'] ?? '';
|
|
$this->ruleProto = $rule['proto'] ?? '';
|
|
$this->ruleDport = $rule['dport'] ?? '';
|
|
$this->ruleSport = $rule['sport'] ?? '';
|
|
$this->ruleComment = $rule['comment'] ?? '';
|
|
$this->ruleEnable = !isset($rule['enable']) || $rule['enable'] != 0;
|
|
$this->ruleMacro = $rule['macro'] ?? '';
|
|
$this->ruleIface = $rule['iface'] ?? '';
|
|
$this->ruleLog = $rule['log'] ?? 'nolog';
|
|
}
|
|
|
|
public function saveRule(): void
|
|
{
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$allowedActions = ['ACCEPT', 'DROP', 'REJECT'];
|
|
if (!in_array($this->ruleAction, $allowedActions, true)) {
|
|
$this->error = 'Invalid rule action. Must be one of: ' . implode(', ', $allowedActions);
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$allowedTypes = ['in', 'out', 'group'];
|
|
if (!in_array($this->ruleType, $allowedTypes, true)) {
|
|
$this->error = 'Invalid rule type. Must be one of: ' . implode(', ', $allowedTypes);
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$allowedLogLevels = ['nolog', 'emerg', 'alert', 'crit', 'err', 'warning', 'notice', 'info', 'debug'];
|
|
if (!in_array($this->ruleLog, $allowedLogLevels, true)) {
|
|
$this->ruleLog = 'nolog';
|
|
}
|
|
|
|
$payload = [
|
|
'action' => $this->ruleAction,
|
|
'type' => $this->ruleType,
|
|
'enable' => $this->ruleEnable ? 1 : 0,
|
|
];
|
|
|
|
// Note: ruleMacro is only used for UI convenience to auto-populate fields
|
|
// It is NOT sent to Proxmox API - only the actual proto/dport values are sent
|
|
|
|
if (!empty(trim($this->ruleIface))) {
|
|
$payload['iface'] = trim($this->ruleIface);
|
|
}
|
|
|
|
if (!empty(trim($this->ruleSource))) {
|
|
$payload['source'] = trim($this->ruleSource);
|
|
}
|
|
|
|
if (!empty(trim($this->ruleDest))) {
|
|
$payload['dest'] = trim($this->ruleDest);
|
|
}
|
|
|
|
if (!empty(trim($this->ruleProto))) {
|
|
$payload['proto'] = trim($this->ruleProto);
|
|
}
|
|
|
|
if (!empty(trim($this->ruleDport))) {
|
|
$payload['dport'] = trim($this->ruleDport);
|
|
}
|
|
|
|
if (!empty(trim($this->ruleSport))) {
|
|
$payload['sport'] = trim($this->ruleSport);
|
|
}
|
|
|
|
if (!empty(trim($this->ruleComment))) {
|
|
$payload['comment'] = trim($this->ruleComment);
|
|
}
|
|
|
|
if (!empty(trim($this->ruleLog)) && $this->ruleLog !== 'nolog') {
|
|
$payload['log'] = $this->ruleLog;
|
|
}
|
|
|
|
if ($this->editingRuleIndex !== null && isset($this->rules[$this->editingRuleIndex])) {
|
|
$pos = $this->rules[$this->editingRuleIndex]['pos'];
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/rules/' . $pos,
|
|
'put',
|
|
$payload
|
|
);
|
|
} else {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/rules',
|
|
'post',
|
|
$payload
|
|
);
|
|
}
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('Rule ' . ($this->editingRuleIndex !== null ? 'updated' : 'created') . ' successfully', 'success');
|
|
$this->resetRuleForm();
|
|
$this->editingRuleIndex = null;
|
|
$this->showRuleForm = false;
|
|
$this->loadFirewallRules();
|
|
} else {
|
|
$errorBody = $response->json();
|
|
$errors = $errorBody['errors'] ?? null;
|
|
$message = is_array($errors) ? implode(', ', array_values($errors)) : ($response->body() ?: 'Unknown error');
|
|
$this->error = 'Failed to save rule: ' . $message;
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to save rule: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
public function toggleRule(int $index): void
|
|
{
|
|
try {
|
|
if (!isset($this->rules[$index])) {
|
|
return;
|
|
}
|
|
|
|
$rule = $this->rules[$index];
|
|
$pos = $rule['pos'];
|
|
$currentEnable = isset($rule['enable']) ? (int)$rule['enable'] : 1;
|
|
$newEnable = $currentEnable == 0 ? 1 : 0;
|
|
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/rules/' . $pos,
|
|
'put',
|
|
['enable' => $newEnable]
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('Rule ' . ($newEnable == 1 ? 'enabled' : 'disabled'), 'success');
|
|
$this->loadFirewallRules();
|
|
} else {
|
|
$this->error = 'Failed to toggle rule';
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to toggle rule: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
}
|
|
}
|
|
|
|
public function confirmDeleteRule(int $index): void
|
|
{
|
|
if (!isset($this->rules[$index])) {
|
|
return;
|
|
}
|
|
|
|
$this->deletingRuleIndex = $index;
|
|
$this->showDeleteRuleModal = true;
|
|
}
|
|
|
|
public function deleteRule(): void
|
|
{
|
|
if ($this->deletingRuleIndex === null || !isset($this->rules[$this->deletingRuleIndex])) {
|
|
return;
|
|
}
|
|
|
|
$rule = $this->rules[$this->deletingRuleIndex];
|
|
$pos = $rule['pos'];
|
|
$this->loading = true;
|
|
$this->showDeleteRuleModal = false;
|
|
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/rules/' . $pos,
|
|
'delete'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('Rule deleted successfully', 'success');
|
|
$this->loadFirewallRules();
|
|
} else {
|
|
$this->error = 'Failed to delete rule';
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to delete rule: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
$this->deletingRuleIndex = null;
|
|
}
|
|
}
|
|
|
|
public function cancelDeleteRule(): void
|
|
{
|
|
$this->deletingRuleIndex = null;
|
|
$this->showDeleteRuleModal = false;
|
|
}
|
|
|
|
public function reorderRules(int $fromPos, int $toPos): void
|
|
{
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/rules/' . $fromPos,
|
|
'put',
|
|
['moveto' => $toPos]
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('Rules reordered successfully', 'success');
|
|
$this->loadFirewallRules();
|
|
} else {
|
|
$this->error = 'Failed to reorder rules';
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to reorder rules: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
}
|
|
}
|
|
|
|
protected function resetRuleForm(): void
|
|
{
|
|
$this->ruleAction = 'ACCEPT';
|
|
$this->ruleType = 'in';
|
|
$this->ruleSource = '';
|
|
$this->ruleDest = '';
|
|
$this->ruleProto = '';
|
|
$this->ruleDport = '';
|
|
$this->ruleSport = '';
|
|
$this->ruleComment = '';
|
|
$this->ruleEnable = true;
|
|
$this->ruleMacro = '';
|
|
$this->ruleIface = '';
|
|
$this->ruleLog = 'nolog';
|
|
}
|
|
|
|
public function cancelRuleForm(): void
|
|
{
|
|
$this->resetRuleForm();
|
|
$this->editingRuleIndex = null;
|
|
$this->showRuleForm = false;
|
|
}
|
|
|
|
// ==================== ALIASES ====================
|
|
|
|
protected function loadAliases(): void
|
|
{
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/aliases'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->aliases = $response->json()['data'] ?? [];
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to load aliases: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function startCreateAlias(): void
|
|
{
|
|
$this->resetAliasForm();
|
|
$this->editingAliasName = null;
|
|
$this->showAliasForm = true;
|
|
}
|
|
|
|
public function startEditAlias(string $name): void
|
|
{
|
|
$alias = collect($this->aliases)->firstWhere('name', $name);
|
|
|
|
if ($alias) {
|
|
$this->editingAliasName = $name;
|
|
$this->aliasName = $alias['name'];
|
|
$this->aliasCidr = $alias['cidr'];
|
|
$this->aliasComment = $alias['comment'] ?? '';
|
|
$this->showAliasForm = true;
|
|
}
|
|
}
|
|
|
|
public function saveAlias(): void
|
|
{
|
|
$this->validate([
|
|
'aliasName' => 'required|string|max:64|regex:/^[a-zA-Z][a-zA-Z0-9_\-]*$/',
|
|
'aliasCidr' => 'required|string',
|
|
]);
|
|
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$payload = [
|
|
'name' => trim($this->aliasName),
|
|
'cidr' => trim($this->aliasCidr),
|
|
];
|
|
|
|
if (!empty(trim($this->aliasComment))) {
|
|
$payload['comment'] = trim($this->aliasComment);
|
|
}
|
|
|
|
if ($this->editingAliasName) {
|
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $this->editingAliasName)) {
|
|
$this->error = 'Invalid alias name format';
|
|
$this->showErrorModal = true;
|
|
$this->loading = false;
|
|
return;
|
|
}
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/aliases/' . $this->editingAliasName,
|
|
'put',
|
|
$payload
|
|
);
|
|
} else {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/aliases',
|
|
'post',
|
|
$payload
|
|
);
|
|
}
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('Alias ' . ($this->editingAliasName ? 'updated' : 'created') . ' successfully', 'success');
|
|
$this->resetAliasForm();
|
|
$this->editingAliasName = null;
|
|
$this->showAliasForm = false;
|
|
$this->loadAliases();
|
|
} else {
|
|
$errorBody = $response->json();
|
|
$errors = $errorBody['errors'] ?? null;
|
|
$message = is_array($errors) ? implode(', ', array_values($errors)) : ($response->body() ?: 'Unknown error');
|
|
$this->error = 'Failed to save alias: ' . $message;
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to save alias: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
public function confirmDeleteAlias(string $name): void
|
|
{
|
|
$this->deletingAliasName = $name;
|
|
$this->showDeleteAliasModal = true;
|
|
}
|
|
|
|
public function deleteAlias(): void
|
|
{
|
|
if (!$this->deletingAliasName) {
|
|
return;
|
|
}
|
|
|
|
// Validate alias name format to prevent path traversal
|
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $this->deletingAliasName)) {
|
|
$this->error = 'Invalid alias name format';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$this->loading = true;
|
|
$this->showDeleteAliasModal = false;
|
|
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/aliases/' . $this->deletingAliasName,
|
|
'delete'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('Alias deleted successfully', 'success');
|
|
$this->loadAliases();
|
|
} else {
|
|
$this->error = 'Failed to delete alias';
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to delete alias: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
$this->deletingAliasName = null;
|
|
}
|
|
}
|
|
|
|
public function cancelDeleteAlias(): void
|
|
{
|
|
$this->deletingAliasName = null;
|
|
$this->showDeleteAliasModal = false;
|
|
}
|
|
|
|
protected function resetAliasForm(): void
|
|
{
|
|
$this->aliasName = '';
|
|
$this->aliasCidr = '';
|
|
$this->aliasComment = '';
|
|
}
|
|
|
|
public function cancelAliasForm(): void
|
|
{
|
|
$this->resetAliasForm();
|
|
$this->editingAliasName = null;
|
|
$this->showAliasForm = false;
|
|
}
|
|
|
|
// ==================== IPSETS ====================
|
|
|
|
protected function loadIPSets(): void
|
|
{
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/ipset'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->ipsets = $response->json()['data'] ?? [];
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to load IPSets: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function startCreateIPSet(): void
|
|
{
|
|
$this->resetIPSetForm();
|
|
$this->editingIPSetName = null;
|
|
$this->showIPSetForm = true;
|
|
}
|
|
|
|
public function saveIPSet(): void
|
|
{
|
|
$this->validate([
|
|
'ipsetName' => 'required|string|max:64|regex:/^[a-zA-Z][a-zA-Z0-9_\-]*$/',
|
|
]);
|
|
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$payload = [
|
|
'name' => trim($this->ipsetName),
|
|
];
|
|
|
|
if (!empty(trim($this->ipsetComment))) {
|
|
$payload['comment'] = trim($this->ipsetComment);
|
|
}
|
|
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/ipset',
|
|
'post',
|
|
$payload
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('IPSet created successfully', 'success');
|
|
$this->resetIPSetForm();
|
|
$this->showIPSetForm = false;
|
|
$this->loadIPSets();
|
|
} else {
|
|
$errorBody = $response->json();
|
|
$errors = $errorBody['errors'] ?? null;
|
|
$message = is_array($errors) ? implode(', ', array_values($errors)) : ($response->body() ?: 'Unknown error');
|
|
$this->error = 'Failed to create IPSet: ' . $message;
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to create IPSet: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
public function confirmDeleteIPSet(string $name): void
|
|
{
|
|
$this->deletingIPSetName = $name;
|
|
$this->showDeleteIPSetModal = true;
|
|
}
|
|
|
|
public function deleteIPSet(): void
|
|
{
|
|
if (!$this->deletingIPSetName) {
|
|
return;
|
|
}
|
|
|
|
// Validate IPSet name format to prevent path traversal
|
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $this->deletingIPSetName)) {
|
|
$this->error = 'Invalid IPSet name format';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$this->loading = true;
|
|
$this->showDeleteIPSetModal = false;
|
|
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/ipset/' . $this->deletingIPSetName,
|
|
'delete'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('IPSet deleted successfully', 'success');
|
|
$this->loadIPSets();
|
|
|
|
// Clear selected IPSet if it was deleted
|
|
if ($this->selectedIPSetName === $this->deletingIPSetName) {
|
|
$this->selectedIPSetName = null;
|
|
$this->ipsetMembers = [];
|
|
}
|
|
} else {
|
|
$this->error = 'Failed to delete IPSet';
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to delete IPSet: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
$this->deletingIPSetName = null;
|
|
}
|
|
}
|
|
|
|
public function cancelDeleteIPSet(): void
|
|
{
|
|
$this->deletingIPSetName = null;
|
|
$this->showDeleteIPSetModal = false;
|
|
}
|
|
|
|
protected function resetIPSetForm(): void
|
|
{
|
|
$this->ipsetName = '';
|
|
$this->ipsetComment = '';
|
|
}
|
|
|
|
public function cancelIPSetForm(): void
|
|
{
|
|
$this->resetIPSetForm();
|
|
$this->editingIPSetName = null;
|
|
$this->showIPSetForm = false;
|
|
}
|
|
|
|
// ==================== IPSET MEMBERS ====================
|
|
|
|
public function selectIPSet(string $name): void
|
|
{
|
|
// Validate IPSet name format to prevent path traversal
|
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $name)) {
|
|
$this->error = 'Invalid IPSet name format';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$this->selectedIPSetName = $name;
|
|
$this->loadIPSetMembers($name);
|
|
}
|
|
|
|
protected function loadIPSetMembers(string $name): void
|
|
{
|
|
try {
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/ipset/' . $name
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->ipsetMembers = $response->json()['data'] ?? [];
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to load IPSet members: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function startAddIPSetMember(): void
|
|
{
|
|
$this->memberCidr = '';
|
|
$this->memberComment = '';
|
|
$this->memberNomatch = false;
|
|
$this->showIPSetMemberForm = true;
|
|
}
|
|
|
|
public function saveIPSetMember(): void
|
|
{
|
|
if (!$this->selectedIPSetName) {
|
|
return;
|
|
}
|
|
|
|
$this->validate([
|
|
'memberCidr' => 'required|string',
|
|
]);
|
|
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$payload = [
|
|
'cidr' => trim($this->memberCidr),
|
|
];
|
|
|
|
if (!empty(trim($this->memberComment))) {
|
|
$payload['comment'] = trim($this->memberComment);
|
|
}
|
|
|
|
if ($this->memberNomatch) {
|
|
$payload['nomatch'] = 1;
|
|
}
|
|
|
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $this->selectedIPSetName ?? '')) {
|
|
$this->error = 'Invalid IPSet name format';
|
|
$this->showErrorModal = true;
|
|
$this->loading = false;
|
|
return;
|
|
}
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/ipset/' . $this->selectedIPSetName,
|
|
'post',
|
|
$payload
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('IP/CIDR added to IPSet successfully', 'success');
|
|
$this->memberCidr = '';
|
|
$this->memberComment = '';
|
|
$this->memberNomatch = false;
|
|
$this->showIPSetMemberForm = false;
|
|
$this->loadIPSetMembers($this->selectedIPSetName);
|
|
} else {
|
|
$errorBody = $response->json();
|
|
$errors = $errorBody['errors'] ?? null;
|
|
$message = is_array($errors) ? implode(', ', array_values($errors)) : ($response->body() ?: 'Unknown error');
|
|
$this->error = 'Failed to add member: ' . $message;
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to add member: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
public function confirmDeleteIPSetMember(string $cidr): void
|
|
{
|
|
$this->deletingMemberCidr = $cidr;
|
|
$this->showDeleteIPSetMemberModal = true;
|
|
}
|
|
|
|
public function deleteIPSetMember(): void
|
|
{
|
|
if (!$this->selectedIPSetName || !$this->deletingMemberCidr) {
|
|
return;
|
|
}
|
|
|
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $this->selectedIPSetName)) {
|
|
$this->error = 'Invalid IPSet name format';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$this->loading = true;
|
|
$this->showDeleteIPSetMemberModal = false;
|
|
|
|
try {
|
|
// URL encode the CIDR for the DELETE request
|
|
$encodedCidr = urlencode($this->deletingMemberCidr);
|
|
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/ipset/' . $this->selectedIPSetName . '/' . $encodedCidr,
|
|
'delete'
|
|
);
|
|
|
|
if ($response->successful()) {
|
|
$this->notify('IP/CIDR removed from IPSet successfully', 'success');
|
|
$this->loadIPSetMembers($this->selectedIPSetName);
|
|
} else {
|
|
$this->error = 'Failed to remove member';
|
|
$this->showErrorModal = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error = 'Failed to remove member: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
$this->deletingMemberCidr = null;
|
|
}
|
|
}
|
|
|
|
public function cancelDeleteIPSetMember(): void
|
|
{
|
|
$this->deletingMemberCidr = null;
|
|
$this->showDeleteIPSetMemberModal = false;
|
|
}
|
|
|
|
public function cancelIPSetMemberForm(): void
|
|
{
|
|
$this->memberCidr = '';
|
|
$this->memberComment = '';
|
|
$this->memberNomatch = false;
|
|
$this->showIPSetMemberForm = false;
|
|
}
|
|
|
|
// ==================== FIREWALL LOGS ====================
|
|
|
|
public function loadFirewallLogs(): void
|
|
{
|
|
$this->logError = '';
|
|
|
|
try {
|
|
// Proxmox returns logs with newest first when using start=0
|
|
// Fetch a larger batch to ensure we get the most recent logs
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/firewall/log',
|
|
'get',
|
|
[
|
|
'limit' => 5000, // Fetch more logs to get the recent ones
|
|
'start' => 0, // Start from the beginning (most recent)
|
|
'_dc' => time() // Cache buster
|
|
]
|
|
);
|
|
|
|
if (!$response->successful()) {
|
|
$this->logError = 'Failed to fetch logs (HTTP ' . $response->status() . ')';
|
|
Log::warning('Firewall log API request failed', [
|
|
'status' => $response->status(),
|
|
'server_id' => $this->server->id,
|
|
'vm_id' => $this->server->vm_id,
|
|
]);
|
|
$this->firewallLogs = [];
|
|
return;
|
|
}
|
|
|
|
// Proxmox firewall log endpoint can return JSON or text/plain
|
|
$body = $response->body();
|
|
$allRawLogs = [];
|
|
|
|
// Check content type
|
|
$contentType = $response->header('Content-Type');
|
|
$isJson = str_contains($contentType ?? '', 'json');
|
|
|
|
if ($isJson) {
|
|
try {
|
|
$jsonData = $response->json();
|
|
|
|
if (isset($jsonData['data'])) {
|
|
if (is_array($jsonData['data'])) {
|
|
foreach ($jsonData['data'] as $item) {
|
|
if (is_array($item) && isset($item['t'])) {
|
|
$allRawLogs[] = $item['t'];
|
|
} elseif (is_string($item)) {
|
|
$allRawLogs[] = $item;
|
|
}
|
|
}
|
|
} elseif (is_string($jsonData['data'])) {
|
|
$lines = preg_split('/\r\n|\r|\n/', $jsonData['data']);
|
|
$allRawLogs = array_filter($lines, fn($line) => !empty(trim($line)));
|
|
}
|
|
} elseif (is_array($jsonData)) {
|
|
foreach ($jsonData as $item) {
|
|
if (is_array($item) && isset($item['t'])) {
|
|
$allRawLogs[] = $item['t'];
|
|
} elseif (is_string($item)) {
|
|
$allRawLogs[] = $item;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::warning('Failed to parse JSON response', ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
// Fall back to text/plain format
|
|
if (empty($allRawLogs) && !empty($body)) {
|
|
$lines = preg_split('/\r\n|\r|\n/', $body);
|
|
$allRawLogs = array_filter($lines, fn($line) => !empty(trim($line)));
|
|
}
|
|
|
|
$allRawLogs = array_values($allRawLogs);
|
|
|
|
Log::info('Firewall logs fetched from Proxmox API', [
|
|
'raw_count' => count($allRawLogs),
|
|
'first_3_logs' => array_slice($allRawLogs, 0, 3)
|
|
]);
|
|
|
|
$this->firewallLogs = $this->parseFirewallLogs($allRawLogs);
|
|
|
|
// Only keep the most recent entries (limit to 500 for performance)
|
|
if (count($this->firewallLogs) > 500) {
|
|
$this->firewallLogs = array_slice($this->firewallLogs, 0, 500);
|
|
}
|
|
|
|
// Reset to page 1 when refreshing logs
|
|
$this->currentPage = 1;
|
|
|
|
// Mark logs as loaded
|
|
$this->logsLoaded = true;
|
|
|
|
// Set last refresh timestamp
|
|
$this->lastLogRefresh = now()->format('Y-m-d H:i:s');
|
|
|
|
// Notify user of successful refresh
|
|
$this->notify('Loaded ' . count($this->firewallLogs) . ' firewall log entries', 'success');
|
|
|
|
Log::info('Firewall log parsing summary', [
|
|
'total_raw_logs' => count($allRawLogs),
|
|
'successfully_parsed' => count($this->firewallLogs),
|
|
'last_refresh' => $this->lastLogRefresh,
|
|
'first_log_timestamp' => $this->firewallLogs[0]['timestamp'] ?? 'none',
|
|
'last_log_timestamp' => $this->firewallLogs[count($this->firewallLogs) - 1]['timestamp'] ?? 'none',
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logError = 'Failed to load logs: ' . $e->getMessage();
|
|
Log::error('Firewall logs error: ' . $e->getMessage());
|
|
$this->firewallLogs = [];
|
|
}
|
|
}
|
|
|
|
// ==================== LOG PAGINATION ====================
|
|
|
|
public function getPaginatedLogsProperty(): array
|
|
{
|
|
if (!$this->logsLoaded || empty($this->firewallLogs)) {
|
|
return [];
|
|
}
|
|
|
|
// Logs are already sorted by timestamp (most recent first) in parseFirewallLogs
|
|
$offset = ($this->currentPage - 1) * $this->perPage;
|
|
return array_slice($this->firewallLogs, $offset, $this->perPage);
|
|
}
|
|
|
|
public function getTotalPagesProperty(): int
|
|
{
|
|
if (!$this->logsLoaded || empty($this->firewallLogs)) {
|
|
return 1;
|
|
}
|
|
return (int)ceil(count($this->firewallLogs) / $this->perPage);
|
|
}
|
|
|
|
public function goToPage(int $page): void
|
|
{
|
|
$totalPages = $this->getTotalPagesProperty();
|
|
if ($page >= 1 && $page <= $totalPages) {
|
|
$this->currentPage = $page;
|
|
}
|
|
}
|
|
|
|
public function previousPage(): void
|
|
{
|
|
if ($this->currentPage > 1) {
|
|
$this->currentPage--;
|
|
}
|
|
}
|
|
|
|
public function nextPage(): void
|
|
{
|
|
if ($this->currentPage < $this->getTotalPagesProperty()) {
|
|
$this->currentPage++;
|
|
}
|
|
}
|
|
|
|
public function updatedPerPage(): void
|
|
{
|
|
// Reset to page 1 when changing per-page value
|
|
$this->currentPage = 1;
|
|
}
|
|
|
|
public function clearLogs(): void
|
|
{
|
|
// Clear logs to reduce payload size when not viewing logs tab
|
|
$this->firewallLogs = [];
|
|
$this->logsLoaded = false;
|
|
$this->currentPage = 1;
|
|
}
|
|
|
|
// ==================== LOG PARSING ====================
|
|
|
|
protected function parseFirewallLogs(array $rawLogs): array
|
|
{
|
|
$parsed = [];
|
|
|
|
foreach ($rawLogs as $logEntry) {
|
|
if (empty($logEntry) || !is_string($logEntry)) {
|
|
continue;
|
|
}
|
|
|
|
$log = $this->parseLogLine($logEntry);
|
|
if ($log) {
|
|
$parsed[] = $log;
|
|
}
|
|
}
|
|
|
|
// Sort logs by timestamp in descending order (most recent first)
|
|
usort($parsed, function($a, $b) {
|
|
$timeA = strtotime($a['timestamp_iso'] ?? $a['timestamp'] ?? '0');
|
|
$timeB = strtotime($b['timestamp_iso'] ?? $b['timestamp'] ?? '0');
|
|
return $timeB <=> $timeA; // Descending order
|
|
});
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
protected function parseLogLine(string $line): ?array
|
|
{
|
|
// Parse Proxmox firewall log format
|
|
// Format: "vmid level chain timestamp action: KEY=VALUE ..."
|
|
|
|
if (!preg_match('/^(\d+)\s+(\d+)\s+(\S+)\s+([^\s]+\s+[^\s]+)\s+(\w+):\s*(.*)$/', $line, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
$fields = [
|
|
'raw' => $line,
|
|
'vmid' => $matches[1],
|
|
'level' => (int)$matches[2],
|
|
'chain' => $matches[3],
|
|
'timestamp' => $matches[4],
|
|
'action' => $matches[5],
|
|
];
|
|
|
|
// Parse key-value pairs
|
|
$kvString = $matches[6];
|
|
if (preg_match_all('/(\w+)=([^\s]+)/', $kvString, $kvMatches, PREG_SET_ORDER)) {
|
|
foreach ($kvMatches as $match) {
|
|
$key = strtolower($match[1]);
|
|
$value = $match[2];
|
|
$fields[$key] = $value;
|
|
}
|
|
}
|
|
|
|
// Map fields to standardized names
|
|
$parsed = [
|
|
'raw' => $line,
|
|
'timestamp' => $fields['timestamp'] ?? '-',
|
|
'timestamp_iso' => $this->convertTimestamp($fields['timestamp'] ?? ''),
|
|
'timestamp_display' => $this->formatTimestampForDisplay($fields['timestamp'] ?? ''),
|
|
'action' => $fields['action'] ?? '-',
|
|
'level' => $fields['level'] ?? 0,
|
|
'level_label' => $this->getLevelLabel($fields['level'] ?? 0),
|
|
'level_badge_class' => $this->getLevelBadgeClass($fields['action'] ?? '-'),
|
|
'chain' => $fields['chain'] ?? '-',
|
|
'interface' => $fields['in'] ?? '-',
|
|
'source' => $fields['src'] ?? '-',
|
|
'destination' => $fields['dst'] ?? '-',
|
|
'source_port' => $fields['spt'] ?? '-',
|
|
'destination_port' => $fields['dpt'] ?? '-',
|
|
'protocol' => $fields['proto'] ?? '-',
|
|
'mac' => $fields['mac'] ?? '-',
|
|
];
|
|
|
|
// Debug log first entry to verify parsing
|
|
static $loggedOnce = false;
|
|
if (!$loggedOnce && count($fields) > 5) {
|
|
Log::info('Firewall log parse sample', [
|
|
'parsed_fields' => $fields,
|
|
'protocol_value' => $parsed['protocol'],
|
|
'source_value' => $parsed['source'],
|
|
]);
|
|
$loggedOnce = true;
|
|
}
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
protected function convertTimestamp(string $timestamp): string
|
|
{
|
|
try {
|
|
$dt = Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp);
|
|
return $dt->toIso8601String();
|
|
} catch (\Exception $e) {
|
|
return $timestamp;
|
|
}
|
|
}
|
|
|
|
protected function formatTimestampForDisplay(string $timestamp): string
|
|
{
|
|
try {
|
|
// Parse the Proxmox timestamp format: 04/Dec/2025:06:49:07 -1000
|
|
$dt = Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp);
|
|
|
|
// Convert to user's timezone (using app timezone)
|
|
$dt->setTimezone(config('app.timezone', 'UTC'));
|
|
|
|
// Format as: Dec 04, 2025 16:49:07 (or use a more readable format)
|
|
return $dt->format('M d, Y H:i:s');
|
|
} catch (\Exception $e) {
|
|
return $timestamp;
|
|
}
|
|
}
|
|
|
|
protected function getLevelLabel(int $level): string
|
|
{
|
|
return match($level) {
|
|
0 => 'Emergency',
|
|
1 => 'Alert',
|
|
2 => 'Critical',
|
|
3 => 'Error',
|
|
4 => 'Warning',
|
|
5 => 'Notice',
|
|
6 => 'Info',
|
|
7 => 'Debug',
|
|
default => 'Unknown',
|
|
};
|
|
}
|
|
|
|
protected function getLevelBadgeClass(string $action): string
|
|
{
|
|
return match(strtoupper($action)) {
|
|
'ACCEPT' => 'bg-success/20 text-success',
|
|
'DROP' => 'bg-error/20 text-error',
|
|
'REJECT' => 'bg-warning/20 text-warning',
|
|
default => 'bg-base/20 text-base/60',
|
|
};
|
|
}
|
|
|
|
// ==================== IP LOCATION ====================
|
|
|
|
public function fetchIPLocation(string $ip): array
|
|
{
|
|
try {
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
return [
|
|
'status' => 'fail',
|
|
'message' => 'Invalid or private IP address'
|
|
];
|
|
}
|
|
|
|
$response = Http::timeout(5)->get("http://ip-api.com/json/{$ip}");
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
if (isset($data['status']) && $data['status'] === 'success') {
|
|
return $data;
|
|
}
|
|
|
|
return [
|
|
'status' => 'fail',
|
|
'message' => $data['message'] ?? 'Unable to fetch location data'
|
|
];
|
|
}
|
|
|
|
return [
|
|
'status' => 'fail',
|
|
'message' => 'Failed to fetch location data (HTTP ' . $response->status() . ')'
|
|
];
|
|
} catch (\Exception $e) {
|
|
return [
|
|
'status' => 'fail',
|
|
'message' => 'Error: ' . $e->getMessage()
|
|
];
|
|
}
|
|
}
|
|
|
|
// ==================== HELPER METHODS ====================
|
|
|
|
public function checkIsPublicIP(string $ip): bool
|
|
{
|
|
if (empty($ip) || $ip === '—' || $ip === '-') {
|
|
return false;
|
|
}
|
|
|
|
// Use PHP's filter_var with appropriate flags
|
|
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
|
}
|
|
|
|
// ==================== NETWORK ANALYSIS INTEGRATION ====================
|
|
|
|
// Cache TTL for firewall states (seconds)
|
|
protected const STATE_CACHE_TTL = 30;
|
|
|
|
public function getNetworkAnalysisForLog(
|
|
string $sourceIP,
|
|
string $destIP,
|
|
string $sourcePort,
|
|
string $destPort,
|
|
string $protocol
|
|
): array {
|
|
try {
|
|
// Check if Core Router is configured
|
|
if (!$this->coreRouterEnabled()) {
|
|
return [
|
|
'connections' => [],
|
|
'error' => 'Core Router is not configured'
|
|
];
|
|
}
|
|
|
|
$client = \Illuminate\Support\Facades\App::make(CoreRouterClient::class);
|
|
|
|
// Use framework cache to persist states across Livewire requests
|
|
$cacheKey = 'proxmox_firewall_states_' . $this->server->id;
|
|
$allStates = Cache::remember($cacheKey, self::STATE_CACHE_TTL, function () use ($client) {
|
|
return $client->listStates() ?? [];
|
|
});
|
|
|
|
// Normalize search parameters once
|
|
$normalizedSourceIP = strtolower(trim($sourceIP));
|
|
$normalizedDestIP = strtolower(trim($destIP));
|
|
$normalizedProtocol = strtoupper(trim($protocol));
|
|
$destPortNum = ($destPort && $destPort !== '-') ? (int)$destPort : null;
|
|
|
|
// Filter states matching the log entry
|
|
$matchingConnections = [];
|
|
$maxMatches = 50; // Limit results for performance
|
|
|
|
foreach ($allStates as $state) {
|
|
if (count($matchingConnections) >= $maxMatches) {
|
|
break;
|
|
}
|
|
|
|
$rawSource = (string)($state['source'] ?? '');
|
|
$rawDestination = (string)($state['destination'] ?? '');
|
|
|
|
// Quick check - skip if source IP not present in either field
|
|
if (strpos(strtolower($rawSource), $normalizedSourceIP) === false &&
|
|
strpos(strtolower($rawDestination), $normalizedSourceIP) === false) {
|
|
continue;
|
|
}
|
|
|
|
// Extract IPs from the state
|
|
$stateSourceIP = $this->extractIpFromFieldFast($rawSource);
|
|
$stateDestIP = $this->extractIpFromFieldFast($rawDestination);
|
|
|
|
$normalizedStateSourceIP = strtolower($stateSourceIP);
|
|
$normalizedStateDestIP = strtolower($stateDestIP);
|
|
|
|
// Check if the log's source IP appears in the state
|
|
$sourceInState = ($normalizedStateSourceIP === $normalizedSourceIP || $normalizedStateDestIP === $normalizedSourceIP);
|
|
|
|
if (!$sourceInState) {
|
|
continue;
|
|
}
|
|
|
|
// Protocol match
|
|
$stateProtocol = strtoupper($state['protocol'] ?? '');
|
|
if (!empty($protocol) && $protocol !== '-' && $normalizedProtocol !== $stateProtocol) {
|
|
continue;
|
|
}
|
|
|
|
// Port match if specified
|
|
if ($destPortNum !== null) {
|
|
$stateSourcePort = $this->extractPortFromField($rawSource);
|
|
$stateDestPort = $this->extractPortFromField($rawDestination);
|
|
|
|
if ($stateDestPort !== $destPortNum && $stateSourcePort !== $destPortNum) {
|
|
continue;
|
|
}
|
|
} else {
|
|
$stateSourcePort = $this->extractPortFromField($rawSource);
|
|
$stateDestPort = $this->extractPortFromField($rawDestination);
|
|
}
|
|
|
|
// Match found - build connection data
|
|
$bytes = (int)($state['bytes_total'] ?? $state['bytes'] ?? 0);
|
|
$packets = (int)($state['packets_total'] ?? $state['packets'] ?? 0);
|
|
|
|
$matchingConnections[] = [
|
|
'protocol' => $stateProtocol,
|
|
'source' => $stateSourceIP,
|
|
'destination' => $stateDestIP,
|
|
'source_port' => $stateSourcePort,
|
|
'destination_port' => $stateDestPort,
|
|
'raw_source' => $rawSource,
|
|
'raw_destination' => $rawDestination,
|
|
'bytes' => $bytes,
|
|
'bytes_human' => $this->formatBytes($bytes),
|
|
'packets' => $packets,
|
|
'state' => strtoupper($state['state'] ?? 'UNKNOWN'),
|
|
'interface' => strtoupper($state['interface'] ?? 'N/A'),
|
|
'direction' => strtolower($state['direction'] ?? '') === 'in' ? 'Inbound' : 'Outbound',
|
|
];
|
|
}
|
|
|
|
// Sort by bytes (highest first)
|
|
usort($matchingConnections, fn($a, $b) => $b['bytes'] <=> $a['bytes']);
|
|
|
|
return [
|
|
'connections' => $matchingConnections,
|
|
'error' => null
|
|
];
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to fetch network analysis for log entry', [
|
|
'error' => $e->getMessage(),
|
|
'source' => $sourceIP,
|
|
]);
|
|
|
|
return [
|
|
'connections' => [],
|
|
'error' => 'Failed to fetch network data: ' . $e->getMessage()
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fast IP extraction without regex for common formats
|
|
*/
|
|
protected function extractIpFromFieldFast(string $field): string
|
|
{
|
|
if (empty($field)) {
|
|
return '';
|
|
}
|
|
|
|
$field = trim($field);
|
|
|
|
// Handle IPv4 "IP:port" format - most common
|
|
$colonPos = strrpos($field, ':');
|
|
if ($colonPos !== false) {
|
|
$potentialIp = substr($field, 0, $colonPos);
|
|
// Check if it's a valid IPv4
|
|
if (filter_var($potentialIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
return $potentialIp;
|
|
}
|
|
}
|
|
|
|
// Handle "IP[port]" format
|
|
$bracketPos = strpos($field, '[');
|
|
if ($bracketPos !== false) {
|
|
return substr($field, 0, $bracketPos);
|
|
}
|
|
|
|
// Return as-is if it's a valid IP
|
|
if (filter_var($field, FILTER_VALIDATE_IP)) {
|
|
return $field;
|
|
}
|
|
|
|
return $field;
|
|
}
|
|
|
|
/**
|
|
* Fast port extraction
|
|
*/
|
|
protected function extractPortFromField(string $field): ?int
|
|
{
|
|
if (empty($field)) {
|
|
return null;
|
|
}
|
|
|
|
// Handle "[port]" format
|
|
if (preg_match('/\[(\d+)\]$/', $field, $matches)) {
|
|
return (int)$matches[1];
|
|
}
|
|
|
|
// Handle "IP:port" format for IPv4
|
|
$colonPos = strrpos($field, ':');
|
|
if ($colonPos !== false) {
|
|
$potentialIp = substr($field, 0, $colonPos);
|
|
if (filter_var($potentialIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
return (int)substr($field, $colonPos + 1);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function coreRouterEnabled(): bool
|
|
{
|
|
$baseUrl = config('corerouter.base_url');
|
|
$apiKey = config('corerouter.api_key');
|
|
$username = config('corerouter.username');
|
|
$password = config('corerouter.password');
|
|
|
|
// Check if base_url is configured and not empty
|
|
$hasBaseUrl = !empty(trim((string) $baseUrl));
|
|
|
|
// Check if API key is configured (not empty after trimming)
|
|
$hasKey = !empty(trim((string) $apiKey));
|
|
|
|
// Check if basic auth credentials are both configured
|
|
$hasBasic = !empty(trim((string) $username)) && !empty(trim((string) $password));
|
|
|
|
// Configuration is valid if base_url is set and at least one auth method is configured
|
|
return $hasBaseUrl && ($hasKey || $hasBasic);
|
|
}
|
|
|
|
protected function formatBytes(int $bytes): string
|
|
{
|
|
if ($bytes === 0) {
|
|
return '0 B';
|
|
}
|
|
|
|
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
$power = floor(log($bytes, 1024));
|
|
$power = min($power, count($units) - 1);
|
|
|
|
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 . '/' . $this->server->node->vmType() . '/' . $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 . '/' . $this->server->node->vmType() . '/' . $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 . '/' . $this->server->node->vmType() . '/' . $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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the rename configuration modal.
|
|
*/
|
|
public function openRenameConfigModal(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->renamingConfigId = $configId;
|
|
$this->renameConfigName = $config->configuration_name;
|
|
$this->showRenameConfigModal = true;
|
|
}
|
|
|
|
/**
|
|
* Close the rename configuration modal.
|
|
*/
|
|
public function closeRenameConfigModal(): void
|
|
{
|
|
$this->renamingConfigId = null;
|
|
$this->renameConfigName = '';
|
|
$this->showRenameConfigModal = false;
|
|
}
|
|
|
|
/**
|
|
* Rename a saved configuration.
|
|
*/
|
|
public function renameConfiguration(): void
|
|
{
|
|
if (!$this->renamingConfigId) {
|
|
return;
|
|
}
|
|
|
|
$this->validate([
|
|
'renameConfigName' => 'required|string|min:1|max:255',
|
|
]);
|
|
|
|
$userId = Auth::id();
|
|
if (!$userId) {
|
|
$this->error = 'You must be logged in to rename configurations';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$this->loading = true;
|
|
|
|
try {
|
|
$config = FirewallConfiguration::forUser($userId)->find($this->renamingConfigId);
|
|
|
|
if (!$config) {
|
|
$this->error = 'Configuration not found or access denied';
|
|
$this->showErrorModal = true;
|
|
return;
|
|
}
|
|
|
|
$config->update([
|
|
'configuration_name' => trim($this->renameConfigName),
|
|
]);
|
|
|
|
$this->notify('Configuration renamed successfully', 'success');
|
|
$this->closeRenameConfigModal();
|
|
$this->loadSavedConfigurations();
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to rename firewall configuration: ' . $e->getMessage());
|
|
$this->error = 'Failed to rename configuration: ' . $e->getMessage();
|
|
$this->showErrorModal = true;
|
|
} finally {
|
|
$this->loading = false;
|
|
}
|
|
}
|
|
|
|
// ==================== 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';
|
|
$vmType = $server->node->vmType();
|
|
|
|
// Step 1: Update policies
|
|
$response = $this->firewallRequest(
|
|
'/nodes/' . $server->node->name . '/' . $vmType . '/' . $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 . '/' . $vmType . '/' . $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 . '/' . $vmType . '/' . $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 . '/' . $vmType . '/' . $server->vm_id . '/firewall/rules',
|
|
'post',
|
|
$rulePayload
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('proxmox::firewall')->layoutData([
|
|
'sidebar' => true,
|
|
]);
|
|
}
|
|
} |