From 0414337add534a478a8124bb1beea88d2c2f34c8 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 25 Jan 2026 03:08:37 +0200 Subject: [PATCH] Fix DNS zone glue for in-zone nameservers --- VERSION | 2 +- app/Filament/Admin/Pages/DnsZones.php | 86 ++++++++++++++ .../Widgets/DomainIpAssignmentsTable.php | 2 + app/Filament/Jabali/Pages/DnsRecords.php | 86 ++++++++++++++ app/Filament/Jabali/Pages/Email.php | 110 +++++++++++------- app/Observers/DomainObserver.php | 85 ++++++++++++++ .../Migration/MigrationDnsSyncService.php | 85 ++++++++++++++ bin/jabali-agent | 57 +++++++++ tests/Unit/MigrationDnsSyncServiceTest.php | 74 ++++++++++++ 9 files changed, 544 insertions(+), 43 deletions(-) create mode 100644 tests/Unit/MigrationDnsSyncServiceTest.php diff --git a/VERSION b/VERSION index 96fd4ae..0332995 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc2 +VERSION=0.9-rc4 diff --git a/app/Filament/Admin/Pages/DnsZones.php b/app/Filament/Admin/Pages/DnsZones.php index 3eaa5de..26435a4 100644 --- a/app/Filament/Admin/Pages/DnsZones.php +++ b/app/Filament/Admin/Pages/DnsZones.php @@ -543,6 +543,16 @@ class DnsZones extends Page implements HasActions, HasForms, HasTable $defaultRecords[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; } + $defaultRecords = $this->appendNameserverRecords( + $defaultRecords, + $domain->domain, + $ns1, + $ns2, + $serverIp, + $serverIpv6, + 3600 + ); + foreach ($defaultRecords as $record) { DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $record)); } @@ -879,6 +889,7 @@ class DnsZones extends Page implements HasActions, HasForms, HasTable { $records = DnsRecord::whereHas('domain', fn ($q) => $q->where('domain', $domain))->get()->toArray(); $settings = DnsSetting::getAll(); + $defaultIp = $settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'; $this->getAgent()->send('dns.sync_zone', [ 'domain' => $domain, @@ -886,7 +897,82 @@ class DnsZones extends Page implements HasActions, HasForms, HasTable 'ns1' => $settings['ns1'] ?? 'ns1.example.com', 'ns2' => $settings['ns2'] ?? 'ns2.example.com', 'admin_email' => $settings['admin_email'] ?? 'admin.example.com', + 'default_ip' => $defaultIp, + 'default_ipv6' => $settings['default_ipv6'] ?? null, 'default_ttl' => $settings['default_ttl'] ?? 3600, ]); } + + /** + * @param array> $records + * @return array> + */ + protected function appendNameserverRecords( + array $records, + string $domain, + string $ns1, + string $ns2, + string $ipv4, + ?string $ipv6, + int $ttl + ): array { + $labels = $this->getNameserverLabels($domain, [$ns1, $ns2]); + + if ($labels === []) { + return $records; + } + + $existingA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'A') + ); + $existingAAAA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'AAAA') + ); + + foreach ($labels as $label) { + if (! in_array($label, $existingA, true)) { + $records[] = ['name' => $label, 'type' => 'A', 'content' => $ipv4, 'ttl' => $ttl, 'priority' => null]; + } + + if ($ipv6 && ! in_array($label, $existingAAAA, true)) { + $records[] = ['name' => $label, 'type' => 'AAAA', 'content' => $ipv6, 'ttl' => $ttl, 'priority' => null]; + } + } + + return $records; + } + + /** + * @param array $nameservers + * @return array + */ + protected function getNameserverLabels(string $domain, array $nameservers): array + { + $domain = rtrim($domain, '.'); + $labels = []; + + foreach ($nameservers as $nameserver) { + $nameserver = rtrim($nameserver ?? '', '.'); + + if ($nameserver === '') { + continue; + } + + if ($nameserver === $domain) { + $label = '@'; + } elseif (str_ends_with($nameserver, '.'.$domain)) { + $label = substr($nameserver, 0, -strlen('.'.$domain)); + } else { + continue; + } + + if ($label !== '@') { + $labels[] = $label; + } + } + + return array_values(array_unique($labels)); + } } diff --git a/app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php b/app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php index c2f010d..ec24cc3 100644 --- a/app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php +++ b/app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php @@ -290,6 +290,7 @@ class DomainIpAssignmentsTable extends Component implements HasActions, HasSchem $settings = DnsSetting::getAll(); $hostname = gethostname() ?: 'localhost'; $serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'; + $serverIpv6 = $settings['default_ipv6'] ?? null; try { $records = $domain->dnsRecords()->get()->toArray(); @@ -300,6 +301,7 @@ class DomainIpAssignmentsTable extends Component implements HasActions, HasSchem 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ipv6' => $serverIpv6, 'default_ttl' => (int) ($settings['default_ttl'] ?? 3600), ]); } catch (Exception $e) { diff --git a/app/Filament/Jabali/Pages/DnsRecords.php b/app/Filament/Jabali/Pages/DnsRecords.php index e3e5498..b27df45 100644 --- a/app/Filament/Jabali/Pages/DnsRecords.php +++ b/app/Filament/Jabali/Pages/DnsRecords.php @@ -588,6 +588,16 @@ class DnsRecords extends Page implements HasActions, HasForms, HasTable $defaultRecords[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; } + $defaultRecords = $this->appendNameserverRecords( + $defaultRecords, + $domain->domain, + $ns1, + $ns2, + $serverIp, + $serverIpv6, + 3600 + ); + foreach ($defaultRecords as $record) { DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $record)); } @@ -773,12 +783,15 @@ class DnsRecords extends Page implements HasActions, HasForms, HasTable try { $records = DnsRecord::whereHas('domain', fn ($q) => $q->where('domain', $domain))->get(); $settings = DnsSetting::getAll(); + $defaultIp = $settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'; $this->getAgent()->send('dns.sync_zone', [ 'domain' => $domain, 'records' => $records->toArray(), 'ns1' => $settings['ns1'] ?? 'ns1.example.com', 'ns2' => $settings['ns2'] ?? 'ns2.example.com', 'admin_email' => $settings['admin_email'] ?? 'admin.example.com', + 'default_ip' => $defaultIp, + 'default_ipv6' => $settings['default_ipv6'] ?? null, 'default_ttl' => $settings['default_ttl'] ?? 3600, ]); } catch (Exception $e) { @@ -796,4 +809,77 @@ class DnsRecords extends Page implements HasActions, HasForms, HasTable ->visible(fn () => $this->selectedDomainId !== null), ]; } + + /** + * @param array> $records + * @return array> + */ + protected function appendNameserverRecords( + array $records, + string $domain, + string $ns1, + string $ns2, + string $ipv4, + ?string $ipv6, + int $ttl + ): array { + $labels = $this->getNameserverLabels($domain, [$ns1, $ns2]); + + if ($labels === []) { + return $records; + } + + $existingA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'A') + ); + $existingAAAA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'AAAA') + ); + + foreach ($labels as $label) { + if (! in_array($label, $existingA, true)) { + $records[] = ['name' => $label, 'type' => 'A', 'content' => $ipv4, 'ttl' => $ttl, 'priority' => null]; + } + + if ($ipv6 && ! in_array($label, $existingAAAA, true)) { + $records[] = ['name' => $label, 'type' => 'AAAA', 'content' => $ipv6, 'ttl' => $ttl, 'priority' => null]; + } + } + + return $records; + } + + /** + * @param array $nameservers + * @return array + */ + protected function getNameserverLabels(string $domain, array $nameservers): array + { + $domain = rtrim($domain, '.'); + $labels = []; + + foreach ($nameservers as $nameserver) { + $nameserver = rtrim($nameserver ?? '', '.'); + + if ($nameserver === '') { + continue; + } + + if ($nameserver === $domain) { + $label = '@'; + } elseif (str_ends_with($nameserver, '.'.$domain)) { + $label = substr($nameserver, 0, -strlen('.'.$domain)); + } else { + continue; + } + + if ($label !== '@') { + $labels[] = $label; + } + } + + return array_values(array_unique($labels)); + } } diff --git a/app/Filament/Jabali/Pages/Email.php b/app/Filament/Jabali/Pages/Email.php index 6cbcd2e..6fa5a36 100644 --- a/app/Filament/Jabali/Pages/Email.php +++ b/app/Filament/Jabali/Pages/Email.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Jabali\Pages; +use App\Filament\Concerns\HasPageTour; use App\Models\Autoresponder; use App\Models\DnsRecord; use App\Models\Domain; @@ -11,6 +12,8 @@ use App\Models\EmailDomain; use App\Models\EmailForwarder; use App\Models\Mailbox; use App\Services\Agent\AgentClient; +use BackedEnum; +use Exception; use Filament\Actions\Action; use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Contracts\HasActions; @@ -22,10 +25,10 @@ use Filament\Forms\Components\Toggle; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Filament\Infolists\Components\TextEntry; -use Filament\Schemas\Components\Section; -use Filament\Schemas\Components\View; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\View; use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -35,19 +38,17 @@ use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Crypt; -use App\Filament\Concerns\HasPageTour; -use BackedEnum; -use Exception; use Livewire\Attributes\Url; -class Email extends Page implements HasForms, HasActions, HasTable +class Email extends Page implements HasActions, HasForms, HasTable { - use InteractsWithForms; - use InteractsWithActions; - use InteractsWithTable; use HasPageTour; + use InteractsWithActions; + use InteractsWithForms; + use InteractsWithTable; + + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-envelope'; - protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-envelope'; protected static ?int $navigationSort = 3; public static function getNavigationLabel(): string @@ -61,11 +62,12 @@ class Email extends Page implements HasForms, HasActions, HasTable public ?string $activeTab = 'mailboxes'; public string $credEmail = ''; + public string $credPassword = ''; protected ?AgentClient $agent = null; - public function getTitle(): string | Htmlable + public function getTitle(): string|Htmlable { return __('Email Management'); } @@ -134,8 +136,9 @@ class Email extends Page implements HasForms, HasActions, HasTable public function getAgent(): AgentClient { if ($this->agent === null) { - $this->agent = new AgentClient(); + $this->agent = new AgentClient; } + return $this->agent; } @@ -153,12 +156,12 @@ class Email extends Page implements HasForms, HasActions, HasTable // Ensure at least one of each required type $password = $lowercase[random_int(0, strlen($lowercase) - 1)] - . $uppercase[random_int(0, strlen($uppercase) - 1)] - . $numbers[random_int(0, strlen($numbers) - 1)] - . $special[random_int(0, strlen($special) - 1)]; + .$uppercase[random_int(0, strlen($uppercase) - 1)] + .$numbers[random_int(0, strlen($numbers) - 1)] + .$special[random_int(0, strlen($special) - 1)]; // Fill the rest with random characters from all types - $allChars = $lowercase . $uppercase . $numbers . $special; + $allChars = $lowercase.$uppercase.$numbers.$special; for ($i = strlen($password); $i < $length; $i++) { $password .= $allChars[random_int(0, strlen($allChars) - 1)]; } @@ -169,7 +172,7 @@ class Email extends Page implements HasForms, HasActions, HasTable public function table(Table $table): Table { - return match($this->activeTab) { + return match ($this->activeTab) { 'mailboxes' => $this->mailboxesTable($table), 'forwarders' => $this->forwardersTable($table), 'autoresponders' => $this->autorespondersTable($table), @@ -197,9 +200,9 @@ class Email extends Page implements HasForms, HasActions, HasTable ->sortable(), TextColumn::make('quota_display') ->label(__('Quota')) - ->getStateUsing(fn (Mailbox $record) => $record->quota_used_formatted . ' / ' . $record->quota_formatted) - ->description(fn (Mailbox $record) => $record->quota_percent . '% ' . __('used')) - ->color(fn (Mailbox $record) => match(true) { + ->getStateUsing(fn (Mailbox $record) => $record->quota_used_formatted.' / '.$record->quota_formatted) + ->description(fn (Mailbox $record) => $record->quota_percent.'% '.__('used')) + ->color(fn (Mailbox $record) => match (true) { $record->quota_percent >= 90 => 'danger', $record->quota_percent >= 80 => 'warning', default => 'gray', @@ -473,7 +476,7 @@ class Email extends Page implements HasForms, HasActions, HasTable ->label(__('Status')) ->badge() ->getStateUsing(function (Autoresponder $record): string { - if (!$record->is_active) { + if (! $record->is_active) { return __('Disabled'); } if ($record->isCurrentlyActive()) { @@ -482,10 +485,11 @@ class Email extends Page implements HasForms, HasActions, HasTable if ($record->start_date && now()->lt($record->start_date)) { return __('Scheduled'); } + return __('Expired'); }) ->color(function (Autoresponder $record): string { - if (!$record->is_active) { + if (! $record->is_active) { return 'gray'; } if ($record->isCurrentlyActive()) { @@ -494,6 +498,7 @@ class Email extends Page implements HasForms, HasActions, HasTable if ($record->start_date && now()->lt($record->start_date)) { return 'warning'; } + return 'danger'; }), TextColumn::make('start_date') @@ -616,7 +621,7 @@ class Email extends Page implements HasForms, HasActions, HasTable return Mailbox::where('email_domain_id', $record->id) ->pluck('local_part') ->mapWithKeys(fn ($local) => [ - $local . '@' . $record->domain->domain => $local . '@' . $record->domain->domain + $local.'@'.$record->domain->domain => $local.'@'.$record->domain->domain, ]) ->toArray(); }) @@ -646,7 +651,7 @@ class Email extends Page implements HasForms, HasActions, HasTable TextColumn::make('status') ->label(__('Status')) ->badge() - ->color(fn (array $record) => match($record['status'] ?? '') { + ->color(fn (array $record) => match ($record['status'] ?? '') { 'sent', 'delivered' => 'success', 'deferred' => 'warning', 'bounced', 'rejected', 'failed' => 'danger', @@ -698,7 +703,7 @@ class Email extends Page implements HasForms, HasActions, HasTable ->label(__('Status')) ->state($record['status'] ?? '-') ->badge() - ->color(match($record['status'] ?? '') { + ->color(match ($record['status'] ?? '') { 'sent', 'delivered' => 'success', 'deferred' => 'warning', 'bounced', 'rejected', 'failed' => 'danger', @@ -763,7 +768,7 @@ class Email extends Page implements HasForms, HasActions, HasTable { $emailDomain = $domain->emailDomain; - if (!$emailDomain) { + if (! $emailDomain) { // Enable email for this domain on the server $this->getAgent()->emailEnableDomain($this->getUsername(), $domain->domain); @@ -802,7 +807,7 @@ class Email extends Page implements HasForms, HasActions, HasTable $dkimContent = "v=DKIM1; k=rsa; p={$cleanKey}"; - if (!$dkimRecord) { + if (! $dkimRecord) { DnsRecord::create([ 'domain_id' => $domain->id, 'name' => "{$selector}._domainkey", @@ -832,6 +837,7 @@ class Email extends Page implements HasForms, HasActions, HasTable $settings = \App\Models\DnsSetting::getAll(); $hostname = gethostname() ?: 'localhost'; $serverIp = trim(shell_exec("hostname -I | awk '{print \$1}'") ?? '') ?: '127.0.0.1'; + $serverIpv6 = $settings['default_ipv6'] ?? null; $this->getAgent()->send('dns.sync_zone', [ 'domain' => $domain->domain, @@ -840,6 +846,7 @@ class Email extends Page implements HasForms, HasActions, HasTable 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ipv6' => $serverIpv6, 'default_ttl' => $settings['default_ttl'] ?? 3600, ]); } catch (Exception $e) { @@ -918,8 +925,9 @@ class Email extends Page implements HasForms, HasActions, HasTable ]) ->action(function (array $data): void { $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); - if (!$domain) { + if (! $domain) { Notification::make()->title(__('Domain not found'))->danger()->send(); + return; } @@ -927,11 +935,12 @@ class Email extends Page implements HasForms, HasActions, HasTable // Get or create EmailDomain (enables email on server if needed) $emailDomain = $this->getOrCreateEmailDomain($domain); - $email = $data['local_part'] . '@' . $domain->domain; - $quotaBytes = (int)$data['quota_mb'] * 1024 * 1024; + $email = $data['local_part'].'@'.$domain->domain; + $quotaBytes = (int) $data['quota_mb'] * 1024 * 1024; if (Mailbox::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { Notification::make()->title(__('Mailbox already exists'))->danger()->send(); + return; } @@ -996,13 +1005,14 @@ class Email extends Page implements HasForms, HasActions, HasTable public function toggleMailbox(int $mailboxId): void { $mailbox = Mailbox::with('emailDomain.domain')->find($mailboxId); - if (!$mailbox) { + if (! $mailbox) { Notification::make()->title(__('Mailbox not found'))->danger()->send(); + return; } try { - $newStatus = !$mailbox->is_active; + $newStatus = ! $mailbox->is_active; $this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus); $mailbox->update(['is_active' => $newStatus]); @@ -1065,8 +1075,9 @@ class Email extends Page implements HasForms, HasActions, HasTable ]) ->action(function (array $data): void { $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); - if (!$domain) { + if (! $domain) { Notification::make()->title(__('Domain not found'))->danger()->send(); + return; } @@ -1075,6 +1086,7 @@ class Email extends Page implements HasForms, HasActions, HasTable if (empty($destinations)) { Notification::make()->title(__('Invalid destination emails'))->danger()->send(); + return; } @@ -1082,15 +1094,17 @@ class Email extends Page implements HasForms, HasActions, HasTable // Get or create EmailDomain (enables email on server if needed) $emailDomain = $this->getOrCreateEmailDomain($domain); - $email = $data['local_part'] . '@' . $domain->domain; + $email = $data['local_part'].'@'.$domain->domain; if (EmailForwarder::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { Notification::make()->title(__('Forwarder already exists'))->danger()->send(); + return; } if (Mailbox::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { Notification::make()->title(__('A mailbox with this address already exists'))->danger()->send(); + return; } @@ -1122,6 +1136,7 @@ class Email extends Page implements HasForms, HasActions, HasTable if (empty($destinations)) { Notification::make()->title(__('Invalid destination emails'))->danger()->send(); + return; } @@ -1143,13 +1158,14 @@ class Email extends Page implements HasForms, HasActions, HasTable public function toggleForwarder(int $forwarderId): void { $forwarder = EmailForwarder::with('emailDomain.domain')->find($forwarderId); - if (!$forwarder) { + if (! $forwarder) { Notification::make()->title(__('Forwarder not found'))->danger()->send(); + return; } try { - $newStatus = !$forwarder->is_active; + $newStatus = ! $forwarder->is_active; $this->getAgent()->send('email.forwarder_toggle', [ 'username' => $this->getUsername(), 'email' => $forwarder->email, @@ -1227,8 +1243,9 @@ class Email extends Page implements HasForms, HasActions, HasTable $mailbox = Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id())) ->find($data['mailbox_id']); - if (!$mailbox) { + if (! $mailbox) { Notification::make()->title(__('Mailbox not found'))->danger()->send(); + return; } @@ -1239,6 +1256,7 @@ class Email extends Page implements HasForms, HasActions, HasTable ->body(__('Edit the existing autoresponder instead.')) ->danger() ->send(); + return; } @@ -1304,7 +1322,7 @@ class Email extends Page implements HasForms, HasActions, HasTable public function toggleAutoresponder(Autoresponder $autoresponder): void { try { - $newStatus = !$autoresponder->is_active; + $newStatus = ! $autoresponder->is_active; $autoresponder->update(['is_active' => $newStatus]); // Update on mail server @@ -1369,6 +1387,7 @@ class Email extends Page implements HasForms, HasActions, HasTable ->body(__('Please select a mailbox to receive catch-all emails')) ->danger() ->send(); + return; } @@ -1439,9 +1458,16 @@ class Email extends Page implements HasForms, HasActions, HasTable protected function formatBytes(int $bytes): string { - if ($bytes < 1024) return $bytes . ' B'; - if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; - if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB'; - return round($bytes / 1073741824, 1) . ' GB'; + if ($bytes < 1024) { + return $bytes.' B'; + } + if ($bytes < 1048576) { + return round($bytes / 1024, 1).' KB'; + } + if ($bytes < 1073741824) { + return round($bytes / 1048576, 1).' MB'; + } + + return round($bytes / 1073741824, 1).' GB'; } } diff --git a/app/Observers/DomainObserver.php b/app/Observers/DomainObserver.php index 7b38bea..5a63cf9 100644 --- a/app/Observers/DomainObserver.php +++ b/app/Observers/DomainObserver.php @@ -71,6 +71,16 @@ class DomainObserver $defaultRecords[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; } + $defaultRecords = $this->appendNameserverRecords( + $defaultRecords, + $domain->domain, + $ns1, + $ns2, + $defaultIp, + $defaultIpv6, + $defaultTtl + ); + foreach ($defaultRecords as $record) { DnsRecord::create(array_merge(['domain_id' => $domain->id], $record)); } @@ -83,6 +93,7 @@ class DomainObserver $records = DnsRecord::where('domain_id', $domain->id)->get()->toArray(); $hostname = $this->getServerHostname(); $serverIp = $this->getServerIp(); + $serverIpv6 = $settings['default_ipv6'] ?? null; $agent = new AgentClient; $agent->send('dns.sync_zone', [ @@ -92,6 +103,7 @@ class DomainObserver 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ipv6' => $serverIpv6, 'default_ttl' => $settings['default_ttl'] ?? 3600, ]); } catch (Exception $e) { @@ -110,4 +122,77 @@ class DomainObserver return $ip ?: '127.0.0.1'; } + + /** + * @param array> $records + * @return array> + */ + protected function appendNameserverRecords( + array $records, + string $domain, + string $ns1, + string $ns2, + string $ipv4, + ?string $ipv6, + int $ttl + ): array { + $labels = $this->getNameserverLabels($domain, [$ns1, $ns2]); + + if ($labels === []) { + return $records; + } + + $existingA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'A') + ); + $existingAAAA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'AAAA') + ); + + foreach ($labels as $label) { + if (! in_array($label, $existingA, true)) { + $records[] = ['name' => $label, 'type' => 'A', 'content' => $ipv4, 'ttl' => $ttl]; + } + + if ($ipv6 && ! in_array($label, $existingAAAA, true)) { + $records[] = ['name' => $label, 'type' => 'AAAA', 'content' => $ipv6, 'ttl' => $ttl]; + } + } + + return $records; + } + + /** + * @param array $nameservers + * @return array + */ + protected function getNameserverLabels(string $domain, array $nameservers): array + { + $domain = rtrim($domain, '.'); + $labels = []; + + foreach ($nameservers as $nameserver) { + $nameserver = rtrim($nameserver ?? '', '.'); + + if ($nameserver === '') { + continue; + } + + if ($nameserver === $domain) { + $label = '@'; + } elseif (str_ends_with($nameserver, '.'.$domain)) { + $label = substr($nameserver, 0, -strlen('.'.$domain)); + } else { + continue; + } + + if ($label !== '@') { + $labels[] = $label; + } + } + + return array_values(array_unique($labels)); + } } diff --git a/app/Services/Migration/MigrationDnsSyncService.php b/app/Services/Migration/MigrationDnsSyncService.php index 50cbf6e..4683ded 100644 --- a/app/Services/Migration/MigrationDnsSyncService.php +++ b/app/Services/Migration/MigrationDnsSyncService.php @@ -53,6 +53,7 @@ class MigrationDnsSyncService $settings = DnsSetting::getAll(); $hostname = $this->getServerHostname(); $serverIp = $this->getServerIp(); + $serverIpv6 = $settings['default_ipv6'] ?? null; $this->agent->send('dns.sync_zone', [ 'domain' => $domain->domain, @@ -61,6 +62,7 @@ class MigrationDnsSyncService 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ipv6' => $serverIpv6, 'default_ttl' => (int) ($settings['default_ttl'] ?? 3600), ]); } catch (Exception $e) { @@ -117,6 +119,16 @@ class MigrationDnsSyncService $records[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; } + $records = $this->appendNameserverRecords( + $records, + $domain->domain, + $ns1, + $ns2, + $defaultIp, + $defaultIpv6, + $defaultTtl + ); + return $records; } @@ -150,6 +162,79 @@ class MigrationDnsSyncService }); } + /** + * @param array> $records + * @return array> + */ + protected function appendNameserverRecords( + array $records, + string $domain, + string $ns1, + string $ns2, + string $ipv4, + ?string $ipv6, + int $ttl + ): array { + $labels = $this->getNameserverLabels($domain, [$ns1, $ns2]); + + if ($labels === []) { + return $records; + } + + $existingA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'A') + ); + $existingAAAA = array_map( + fn (array $record): string => $record['name'] ?? '', + array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'AAAA') + ); + + foreach ($labels as $label) { + if (! in_array($label, $existingA, true)) { + $records[] = ['name' => $label, 'type' => 'A', 'content' => $ipv4, 'ttl' => $ttl]; + } + + if ($ipv6 && ! in_array($label, $existingAAAA, true)) { + $records[] = ['name' => $label, 'type' => 'AAAA', 'content' => $ipv6, 'ttl' => $ttl]; + } + } + + return $records; + } + + /** + * @param array $nameservers + * @return array + */ + protected function getNameserverLabels(string $domain, array $nameservers): array + { + $domain = rtrim($domain, '.'); + $labels = []; + + foreach ($nameservers as $nameserver) { + $nameserver = rtrim($nameserver ?? '', '.'); + + if ($nameserver === '') { + continue; + } + + if ($nameserver === $domain) { + $label = '@'; + } elseif (str_ends_with($nameserver, '.'.$domain)) { + $label = substr($nameserver, 0, -strlen('.'.$domain)); + } else { + continue; + } + + if ($label !== '@') { + $labels[] = $label; + } + } + + return array_values(array_unique($labels)); + } + /** * @param Collection $records * @return array> diff --git a/bin/jabali-agent b/bin/jabali-agent index 1d3d508..3387353 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -7251,12 +7251,42 @@ function dnsSyncZone(array $params): array { $ns2 = $params['ns2'] ?? 'ns2.example.com'; $adminEmail = $params['admin_email'] ?? 'admin.example.com'; $defaultTtl = $params['default_ttl'] ?? 3600; + $defaultIpv4 = $params['default_ip'] ?? null; + $defaultIpv6 = $params['default_ipv6'] ?? null; if (empty($domain)) return ['success' => false, 'error' => 'Domain required']; $zonesDir = '/etc/bind/zones'; $zoneFile = "{$zonesDir}/db.{$domain}"; $isNew = !file_exists($zoneFile); + $domainName = rtrim($domain, '.'); + $ns1 = rtrim($ns1, '.'); + $ns2 = rtrim($ns2, '.'); + + $recordMap = ['A' => [], 'AAAA' => []]; + + foreach ($records as $r) { + $type = strtoupper($r['type'] ?? 'A'); + if (!in_array($type, ['A', 'AAAA'], true)) continue; + $name = trim($r['name'] ?? '@'); + if ($name === '' || $name === '@') { + $fqdn = $domainName; + } else { + $fqdn = rtrim($name, '.'); + if (substr($fqdn, -strlen(".{$domainName}")) !== ".{$domainName}") { + $fqdn .= ".{$domainName}"; + } + } + $recordMap[$type][$fqdn] = $r['content'] ?? ''; + } + + if (empty($defaultIpv4)) { + $defaultIpv4 = $recordMap['A'][$domainName] ?? (!empty($recordMap['A']) ? reset($recordMap['A']) : null); + } + + if (empty($defaultIpv6)) { + $defaultIpv6 = $recordMap['AAAA'][$domainName] ?? (!empty($recordMap['AAAA']) ? reset($recordMap['AAAA']) : null); + } // Ensure zones directory exists if (!is_dir($zonesDir)) { @@ -7267,6 +7297,33 @@ function dnsSyncZone(array $params): array { $zoneContent = "\$TTL {$defaultTtl}\n@ IN SOA {$ns1}. {$adminEmail}. ({$serial} 3600 1800 604800 86400)\n"; $zoneContent .= "@ IN NS {$ns1}.\n@ IN NS {$ns2}.\n"; + $glueNames = []; + foreach (array_filter([$ns1, $ns2]) as $ns) { + if ($ns === $domainName) { + $label = '@'; + } elseif (substr($ns, -strlen(".{$domainName}")) === ".{$domainName}") { + $label = substr($ns, 0, -strlen(".{$domainName}")); + } else { + continue; + } + + $glueNames[$label] = $ns; + } + + foreach ($glueNames as $label => $fqdn) { + if ($label === '@') { + continue; + } + + if (!isset($recordMap['A'][$fqdn]) && !empty($defaultIpv4)) { + $zoneContent .= "{$label}\tIN\tA\t{$defaultIpv4}\n"; + } + + if (!isset($recordMap['AAAA'][$fqdn]) && !empty($defaultIpv6)) { + $zoneContent .= "{$label}\tIN\tAAAA\t{$defaultIpv6}\n"; + } + } + foreach ($records as $r) { $name = $r['name'] ?? '@'; $type = $r['type'] ?? 'A'; diff --git a/tests/Unit/MigrationDnsSyncServiceTest.php b/tests/Unit/MigrationDnsSyncServiceTest.php new file mode 100644 index 0000000..997c305 --- /dev/null +++ b/tests/Unit/MigrationDnsSyncServiceTest.php @@ -0,0 +1,74 @@ +create([ + 'domain' => 'jabali-panel.com', + 'ip_address' => null, + 'ipv6_address' => null, + ]); + }); + + $this->assertNotNull($domain); + + $service = new MigrationDnsSyncService($this->createMock(AgentClient::class)); + $method = new ReflectionMethod($service, 'getDefaultRecords'); + $method->setAccessible(true); + + $records = $method->invoke($service, $domain); + + $this->assertTrue($this->recordExists($records, 'ns1', 'A', '182.54.236.100')); + $this->assertTrue($this->recordExists($records, 'ns2', 'A', '182.54.236.100')); + $this->assertTrue($this->recordExists($records, 'ns1', 'AAAA', '2001:db8::1')); + $this->assertTrue($this->recordExists($records, 'ns2', 'AAAA', '2001:db8::1')); + } + + /** + * @param array> $records + */ + private function recordExists(array $records, string $name, string $type, string $content): bool + { + foreach ($records as $record) { + if (($record['name'] ?? null) !== $name) { + continue; + } + + if (($record['type'] ?? null) !== $type) { + continue; + } + + if (($record['content'] ?? null) !== $content) { + continue; + } + + return true; + } + + return false; + } +}