Fix DNS zone glue for in-zone nameservers

This commit is contained in:
root
2026-01-25 03:08:37 +02:00
parent f6402cffc4
commit 0414337add
9 changed files with 544 additions and 43 deletions

View File

@@ -1 +1 @@
VERSION=0.9-rc2
VERSION=0.9-rc4

View File

@@ -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<int, array<string, mixed>> $records
* @return array<int, array<string, mixed>>
*/
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<int, string> $nameservers
* @return array<int, string>
*/
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));
}
}

View File

@@ -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) {

View File

@@ -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<int, array<string, mixed>> $records
* @return array<int, array<string, mixed>>
*/
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<int, string> $nameservers
* @return array<int, string>
*/
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));
}
}

View File

@@ -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';
}
}

View File

@@ -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<int, array<string, mixed>> $records
* @return array<int, array<string, mixed>>
*/
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<int, string> $nameservers
* @return array<int, string>
*/
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));
}
}

View File

@@ -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<int, array<string, mixed>> $records
* @return array<int, array<string, mixed>>
*/
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<int, string> $nameservers
* @return array<int, string>
*/
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<int, DnsRecord> $records
* @return array<int, array<string, mixed>>

View File

@@ -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';

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Models\DnsSetting;
use App\Models\Domain;
use App\Services\Agent\AgentClient;
use App\Services\Migration\MigrationDnsSyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use ReflectionMethod;
use Tests\TestCase;
class MigrationDnsSyncServiceTest extends TestCase
{
use RefreshDatabase;
public function test_default_records_include_nameserver_glue_records(): void
{
DnsSetting::set('ns1', 'ns1.jabali-panel.com');
DnsSetting::set('ns2', 'ns2.jabali-panel.com');
DnsSetting::set('default_ip', '182.54.236.100');
DnsSetting::set('default_ipv6', '2001:db8::1');
DnsSetting::clearCache();
$domain = null;
Domain::withoutEvents(function () use (&$domain): void {
$domain = Domain::factory()->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<int, array<string, mixed>> $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;
}
}