883 lines
37 KiB
PHP
883 lines
37 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Jabali\Pages;
|
|
|
|
use App\Filament\Jabali\Widgets\DnsPendingAddsTable;
|
|
use App\Models\DnsRecord;
|
|
use App\Models\DnsSetting;
|
|
use App\Models\Domain;
|
|
use App\Services\Agent\AgentClient;
|
|
use BackedEnum;
|
|
use Exception;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\Action as TableAction;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\Concerns\InteractsWithActions;
|
|
use Filament\Actions\Contracts\HasActions;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Concerns\InteractsWithForms;
|
|
use Filament\Forms\Contracts\HasForms;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\EmbeddedSchema;
|
|
use Filament\Schemas\Components\EmbeddedTable;
|
|
use Filament\Schemas\Components\EmptyState;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Support\Enums\FontFamily;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Contracts\Support\Htmlable;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Attributes\On;
|
|
|
|
class DnsRecords extends Page implements HasActions, HasForms, HasTable
|
|
{
|
|
use InteractsWithActions;
|
|
use InteractsWithForms;
|
|
use InteractsWithTable;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-server-stack';
|
|
|
|
protected static ?int $navigationSort = 11;
|
|
|
|
public static function getNavigationLabel(): string
|
|
{
|
|
return __('DNS Records');
|
|
}
|
|
|
|
public ?int $selectedDomainId = null;
|
|
|
|
protected ?AgentClient $agent = null;
|
|
|
|
// Pending changes tracking
|
|
public array $pendingEdits = [];
|
|
|
|
public array $pendingDeletes = [];
|
|
|
|
public array $pendingAdds = [];
|
|
|
|
public function getTitle(): string|Htmlable
|
|
{
|
|
return __('DNS Records');
|
|
}
|
|
|
|
public function getAgent(): AgentClient
|
|
{
|
|
return $this->agent ??= new AgentClient;
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->selectedDomainId = null;
|
|
}
|
|
|
|
public function form(Schema $form): Schema
|
|
{
|
|
return $form
|
|
->schema([
|
|
Select::make('selectedDomainId')
|
|
->label(__('Select Domain'))
|
|
->options(fn () => Domain::where('user_id', Auth::id())->orderBy('domain')->pluck('domain', 'id')->toArray())
|
|
->searchable()
|
|
->preload()
|
|
->live()
|
|
->afterStateUpdated(fn () => $this->onDomainChange())
|
|
->placeholder(__('Select a domain to manage DNS records')),
|
|
]);
|
|
}
|
|
|
|
public function content(Schema $schema): Schema
|
|
{
|
|
return $schema->schema([
|
|
Section::make(__('Select Domain'))
|
|
->description(__('Choose a domain to manage its DNS records.'))
|
|
->schema([
|
|
EmbeddedSchema::make('form'),
|
|
])
|
|
->visible(fn () => $this->hasDomains()),
|
|
Section::make(__('New Records to Add'))
|
|
->description(__('These records will be created when you save changes.'))
|
|
->icon('heroicon-o-plus-circle')
|
|
->iconColor('success')
|
|
->collapsible()
|
|
->schema([
|
|
EmbeddedTable::make(DnsPendingAddsTable::class, fn () => [
|
|
'records' => $this->pendingAdds,
|
|
]),
|
|
])
|
|
->headerActions([
|
|
Action::make('clearPending')
|
|
->label(__('Clear All'))
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->size('sm')
|
|
->requiresConfirmation()
|
|
->action(fn () => $this->clearPendingAdds()),
|
|
])
|
|
->visible(fn () => $this->selectedDomainId !== null && count($this->pendingAdds) > 0),
|
|
Section::make(__('Important'))
|
|
->description(__('Incorrect DNS changes can make your website or email unreachable. Changes may take up to 48 hours to propagate globally.'))
|
|
->icon('heroicon-o-exclamation-triangle')
|
|
->iconColor('warning')
|
|
->compact()
|
|
->visible(fn () => $this->selectedDomainId !== null),
|
|
Section::make(__('Unsaved Changes'))
|
|
->description(fn () => __('You have :count pending change(s). Click Save to apply them.', ['count' => $this->getPendingChangesCount()]))
|
|
->icon('heroicon-o-clock')
|
|
->iconColor('info')
|
|
->compact()
|
|
->visible(fn () => $this->selectedDomainId !== null && $this->hasPendingChanges()),
|
|
EmbeddedTable::make()
|
|
->visible(fn () => $this->selectedDomainId !== null),
|
|
EmptyState::make(__('No Domain Selected'))
|
|
->description(__('Select a domain from the dropdown above to view and manage its DNS records.'))
|
|
->icon('heroicon-o-cursor-arrow-rays')
|
|
->iconColor('gray')
|
|
->visible(fn () => $this->hasDomains() && $this->selectedDomainId === null),
|
|
EmptyState::make(__('No Domains Found'))
|
|
->description(__('You need to add a domain before you can manage DNS records.'))
|
|
->icon('heroicon-o-globe-alt')
|
|
->iconColor('gray')
|
|
->footer([
|
|
Action::make('addDomain')
|
|
->label(__('Add Domain'))
|
|
->icon('heroicon-o-plus')
|
|
->url(route('filament.jabali.pages.domains')),
|
|
])
|
|
->visible(fn () => ! $this->hasDomains()),
|
|
]);
|
|
}
|
|
|
|
public function onDomainChange(): void
|
|
{
|
|
$this->pendingEdits = [];
|
|
$this->pendingDeletes = [];
|
|
$this->pendingAdds = [];
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function updatedSelectedDomainId(): void
|
|
{
|
|
$this->onDomainChange();
|
|
}
|
|
|
|
public function hasPendingChanges(): bool
|
|
{
|
|
return count($this->pendingEdits) > 0 || count($this->pendingDeletes) > 0 || count($this->pendingAdds) > 0;
|
|
}
|
|
|
|
public function getPendingChangesCount(): int
|
|
{
|
|
return count($this->pendingEdits) + count($this->pendingDeletes) + count($this->pendingAdds);
|
|
}
|
|
|
|
public function isRecordPendingDelete(int $recordId): bool
|
|
{
|
|
return in_array($recordId, $this->pendingDeletes);
|
|
}
|
|
|
|
public function isRecordPendingEdit(int $recordId): bool
|
|
{
|
|
return isset($this->pendingEdits[$recordId]);
|
|
}
|
|
|
|
public function clearPendingAdds(): void
|
|
{
|
|
$this->pendingAdds = [];
|
|
Notification::make()->title(__('Pending records cleared'))->success()->send();
|
|
}
|
|
|
|
#[On('dns-pending-add-remove')]
|
|
public function removePendingAddFromTable(string $key): void
|
|
{
|
|
$this->removePendingAdd($key);
|
|
}
|
|
|
|
public function removePendingAdd(int|string $identifier): void
|
|
{
|
|
if (is_int($identifier)) {
|
|
unset($this->pendingAdds[$identifier]);
|
|
$this->pendingAdds = array_values($this->pendingAdds);
|
|
Notification::make()->title(__('Pending record removed'))->success()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->pendingAdds = array_values(array_filter(
|
|
$this->pendingAdds,
|
|
fn (array $record): bool => ($record['key'] ?? null) !== $identifier
|
|
));
|
|
Notification::make()->title(__('Pending record removed'))->success()->send();
|
|
}
|
|
|
|
protected function queuePendingAdd(array $record): void
|
|
{
|
|
$record['key'] ??= (string) Str::uuid();
|
|
$this->pendingAdds[] = $record;
|
|
}
|
|
|
|
protected function sanitizePendingAdd(array $record): array
|
|
{
|
|
unset($record['key']);
|
|
|
|
return $record;
|
|
}
|
|
|
|
protected function hasDomains(): bool
|
|
{
|
|
return Domain::query()->where('user_id', Auth::id())->exists();
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->query(
|
|
DnsRecord::query()
|
|
->whereHas('domain', fn (Builder $query) => $query->where('user_id', Auth::id()))
|
|
->when($this->selectedDomainId, fn (Builder $query) => $query->where('domain_id', $this->selectedDomainId))
|
|
->orderByRaw("CASE type
|
|
WHEN 'NS' THEN 1
|
|
WHEN 'A' THEN 2
|
|
WHEN 'AAAA' THEN 3
|
|
WHEN 'CNAME' THEN 4
|
|
WHEN 'MX' THEN 5
|
|
WHEN 'TXT' THEN 6
|
|
WHEN 'SRV' THEN 7
|
|
WHEN 'CAA' THEN 8
|
|
ELSE 9 END")
|
|
->orderBy('name')
|
|
)
|
|
->columns([
|
|
TextColumn::make('type')
|
|
->label(__('Type'))
|
|
->badge()
|
|
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : match ($record->type) {
|
|
'A', 'AAAA' => 'info',
|
|
'CNAME' => 'primary',
|
|
'MX' => 'warning',
|
|
'TXT' => 'success',
|
|
'NS' => 'danger',
|
|
'SRV' => 'primary',
|
|
'CAA' => 'warning',
|
|
default => 'gray',
|
|
})
|
|
->sortable(),
|
|
TextColumn::make('name')
|
|
->label(__('Name'))
|
|
->fontFamily(FontFamily::Mono)
|
|
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
|
|
->searchable(),
|
|
TextColumn::make('content')
|
|
->label(__('Content'))
|
|
->fontFamily(FontFamily::Mono)
|
|
->limit(50)
|
|
->tooltip(fn ($record) => $record->content)
|
|
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : ($this->isRecordPendingEdit($record->id) ? 'warning' : null))
|
|
->searchable(),
|
|
TextColumn::make('ttl')
|
|
->label(__('TTL'))
|
|
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
|
|
->sortable(),
|
|
TextColumn::make('priority')
|
|
->label(__('Priority'))
|
|
->placeholder('-')
|
|
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
|
|
->sortable(),
|
|
])
|
|
->filters([])
|
|
->headerActions([
|
|
TableAction::make('resetToDefaults')
|
|
->label(__('Reset to Defaults'))
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('gray')
|
|
->requiresConfirmation()
|
|
->modalHeading(__('Reset DNS Records'))
|
|
->modalDescription(__('This will delete all existing DNS records and create default records. This action cannot be undone.'))
|
|
->modalIcon('heroicon-o-exclamation-triangle')
|
|
->modalIconColor('warning')
|
|
->action(fn () => $this->resetToDefaults()),
|
|
TableAction::make('discardChanges')
|
|
->label(__('Discard'))
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn () => $this->hasPendingChanges())
|
|
->action(fn () => $this->discardChanges()),
|
|
TableAction::make('saveChanges')
|
|
->label(fn () => $this->hasPendingChanges()
|
|
? __('Save (:count changes)', ['count' => $this->getPendingChangesCount()])
|
|
: __('Save'))
|
|
->icon('heroicon-o-check')
|
|
->color('primary')
|
|
->action(fn () => $this->saveChanges()),
|
|
])
|
|
->actions([
|
|
ActionGroup::make([
|
|
TableAction::make('edit')
|
|
->label(__('Edit'))
|
|
->icon('heroicon-o-pencil')
|
|
->color(fn (DnsRecord $record) => $this->isRecordPendingEdit($record->id) ? 'warning' : 'gray')
|
|
->hidden(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id))
|
|
->modalHeading(__('Edit DNS Record'))
|
|
->modalDescription(__('Changes will be queued until you click Save.'))
|
|
->modalIcon('heroicon-o-pencil-square')
|
|
->modalIconColor('primary')
|
|
->modalSubmitActionLabel(__('Queue Changes'))
|
|
->fillForm(fn (DnsRecord $record) => [
|
|
'type' => $this->pendingEdits[$record->id]['type'] ?? $record->type,
|
|
'name' => $this->pendingEdits[$record->id]['name'] ?? $record->name,
|
|
'content' => $this->pendingEdits[$record->id]['content'] ?? $record->content,
|
|
'ttl' => $this->pendingEdits[$record->id]['ttl'] ?? $record->ttl,
|
|
'priority' => $this->pendingEdits[$record->id]['priority'] ?? $record->priority,
|
|
])
|
|
->form($this->getRecordFormSchema())
|
|
->action(function (DnsRecord $record, array $data): void {
|
|
if ($record->domain->user_id !== Auth::id()) {
|
|
Notification::make()->title(__('Access denied'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
$this->pendingEdits[$record->id] = [
|
|
'type' => $data['type'],
|
|
'name' => $data['name'],
|
|
'content' => $data['content'],
|
|
'ttl' => $data['ttl'] ?? 3600,
|
|
'priority' => $data['priority'] ?? null,
|
|
];
|
|
Notification::make()
|
|
->title(__('Edit queued'))
|
|
->body(__('Click Save to apply.'))
|
|
->info()
|
|
->send();
|
|
}),
|
|
TableAction::make('undoEdit')
|
|
->label(__('Undo Edit'))
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('warning')
|
|
->visible(fn (DnsRecord $record) => $this->isRecordPendingEdit($record->id))
|
|
->action(function (DnsRecord $record): void {
|
|
unset($this->pendingEdits[$record->id]);
|
|
Notification::make()->title(__('Edit undone'))->success()->send();
|
|
}),
|
|
TableAction::make('undoDelete')
|
|
->label(__('Undo Delete'))
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('success')
|
|
->visible(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id))
|
|
->action(function (DnsRecord $record): void {
|
|
$this->pendingDeletes = array_values(array_diff($this->pendingDeletes, [$record->id]));
|
|
Notification::make()->title(__('Delete undone'))->success()->send();
|
|
}),
|
|
TableAction::make('delete')
|
|
->label(__('Delete'))
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->hidden(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id))
|
|
->requiresConfirmation()
|
|
->modalHeading(__('Delete Record'))
|
|
->modalDescription(fn (DnsRecord $record) => __('Delete the :type record for :name?', ['type' => $record->type, 'name' => $record->name]))
|
|
->modalIcon('heroicon-o-trash')
|
|
->modalIconColor('danger')
|
|
->modalSubmitActionLabel(__('Queue Delete'))
|
|
->action(function (DnsRecord $record): void {
|
|
if ($record->domain->user_id !== Auth::id()) {
|
|
Notification::make()->title(__('Access denied'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
if (! in_array($record->id, $this->pendingDeletes)) {
|
|
$this->pendingDeletes[] = $record->id;
|
|
}
|
|
unset($this->pendingEdits[$record->id]);
|
|
Notification::make()
|
|
->title(__('Delete queued'))
|
|
->body(__('Click Save to apply.'))
|
|
->warning()
|
|
->send();
|
|
}),
|
|
]),
|
|
])
|
|
->emptyStateHeading(__('No DNS records'))
|
|
->emptyStateDescription(__('Add DNS records to manage your domain\'s DNS configuration.'))
|
|
->emptyStateIcon('heroicon-o-server-stack')
|
|
->striped();
|
|
}
|
|
|
|
protected function getRecordFormSchema(): array
|
|
{
|
|
return [
|
|
Select::make('type')
|
|
->label(__('Record Type'))
|
|
->options([
|
|
'A' => __('A - IPv4 Address'),
|
|
'AAAA' => __('AAAA - IPv6 Address'),
|
|
'CNAME' => __('CNAME - Canonical Name'),
|
|
'MX' => __('MX - Mail Exchange'),
|
|
'TXT' => __('TXT - Text Record'),
|
|
'NS' => __('NS - Nameserver'),
|
|
'SRV' => __('SRV - Service'),
|
|
'CAA' => __('CAA - Certificate Authority'),
|
|
])
|
|
->required()
|
|
->live(),
|
|
TextInput::make('name')
|
|
->label(__('Name'))
|
|
->placeholder(__('@ for root, or subdomain (e.g., www, mail)'))
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('content')
|
|
->label(__('Content'))
|
|
->required()
|
|
->maxLength(1024),
|
|
TextInput::make('ttl')
|
|
->label(__('TTL (seconds)'))
|
|
->numeric()
|
|
->default(3600)
|
|
->minValue(60)
|
|
->maxValue(86400),
|
|
TextInput::make('priority')
|
|
->label(__('Priority'))
|
|
->numeric()
|
|
->visible(fn ($get) => in_array($get('type'), ['MX', 'SRV']))
|
|
->default(10),
|
|
];
|
|
}
|
|
|
|
public function getSelectedDomainName(): ?string
|
|
{
|
|
return Domain::find($this->selectedDomainId)?->domain;
|
|
}
|
|
|
|
public function addRecordAction(): Action
|
|
{
|
|
return Action::make('addRecord')
|
|
->label(__('Add Record'))
|
|
->icon('heroicon-o-plus')
|
|
->color('primary')
|
|
->modalHeading(__('Add DNS Record'))
|
|
->modalDescription(__('The record will be queued until you click Save.'))
|
|
->modalIcon('heroicon-o-plus-circle')
|
|
->modalIconColor('primary')
|
|
->modalSubmitActionLabel(__('Queue Record'))
|
|
->modalWidth('lg')
|
|
->form($this->getRecordFormSchema())
|
|
->action(function (array $data) {
|
|
$this->queuePendingAdd([
|
|
'type' => $data['type'],
|
|
'name' => $data['name'],
|
|
'content' => $data['content'],
|
|
'ttl' => $data['ttl'] ?? 3600,
|
|
'priority' => $data['priority'] ?? null,
|
|
]);
|
|
Notification::make()
|
|
->title(__('Record queued'))
|
|
->body(__('Click Save to apply.'))
|
|
->info()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
public function saveChanges(bool $notify = true): void
|
|
{
|
|
if (! $this->hasPendingChanges()) {
|
|
if ($notify) {
|
|
Notification::make()->title(__('No changes to save'))->warning()->send();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$domain = Domain::find($this->selectedDomainId);
|
|
if (! $domain || $domain->user_id !== Auth::id()) {
|
|
Notification::make()->title(__('Access denied'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
foreach ($this->pendingDeletes as $recordId) {
|
|
DnsRecord::where('id', $recordId)
|
|
->whereHas('domain', fn ($q) => $q->where('user_id', Auth::id()))
|
|
->delete();
|
|
}
|
|
|
|
foreach ($this->pendingEdits as $recordId => $data) {
|
|
$record = DnsRecord::find($recordId);
|
|
if ($record && $record->domain->user_id === Auth::id()) {
|
|
$record->update($data);
|
|
}
|
|
}
|
|
|
|
foreach ($this->pendingAdds as $data) {
|
|
DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $this->sanitizePendingAdd($data)));
|
|
}
|
|
|
|
$this->syncZoneFile($domain->domain);
|
|
|
|
$this->pendingEdits = [];
|
|
$this->pendingDeletes = [];
|
|
$this->pendingAdds = [];
|
|
|
|
$this->resetTable();
|
|
|
|
if ($notify) {
|
|
Notification::make()
|
|
->title(__('Changes saved'))
|
|
->body(__('DNS records updated. Changes may take up to 48 hours to propagate.'))
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('Failed to save changes'))
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
}
|
|
}
|
|
|
|
public function discardChanges(): void
|
|
{
|
|
$this->pendingEdits = [];
|
|
$this->pendingDeletes = [];
|
|
$this->pendingAdds = [];
|
|
Notification::make()->title(__('Changes discarded'))->success()->send();
|
|
}
|
|
|
|
public function resetToDefaults(): void
|
|
{
|
|
$domain = Domain::find($this->selectedDomainId);
|
|
if (! $domain || $domain->user_id !== Auth::id()) {
|
|
Notification::make()->title(__('Access denied'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
DnsRecord::where('domain_id', $this->selectedDomainId)->delete();
|
|
|
|
$settings = DnsSetting::getAll();
|
|
$serverIp = $domain->ip_address ?: ($settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1');
|
|
$serverIpv6 = $domain->ipv6_address ?: ($settings['default_ipv6'] ?? null);
|
|
$ns1 = $settings['ns1'] ?? 'ns1.'.$domain->domain;
|
|
$ns2 = $settings['ns2'] ?? 'ns2.'.$domain->domain;
|
|
|
|
$defaultRecords = [
|
|
['name' => '@', 'type' => 'NS', 'content' => $ns1, 'ttl' => 3600, 'priority' => null],
|
|
['name' => '@', 'type' => 'NS', 'content' => $ns2, 'ttl' => 3600, 'priority' => null],
|
|
['name' => '@', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
|
|
['name' => 'www', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
|
|
['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domain->domain, 'ttl' => 3600, 'priority' => 10],
|
|
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null],
|
|
];
|
|
|
|
if (! empty($serverIpv6)) {
|
|
$defaultRecords[] = ['name' => '@', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null];
|
|
$defaultRecords[] = ['name' => 'www', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null];
|
|
$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));
|
|
}
|
|
|
|
$this->syncZoneFile($domain->domain);
|
|
|
|
$this->pendingEdits = [];
|
|
$this->pendingDeletes = [];
|
|
$this->pendingAdds = [];
|
|
|
|
$this->resetTable();
|
|
|
|
Notification::make()
|
|
->title(__('DNS records reset'))
|
|
->body(__('Default records have been created for :domain', ['domain' => $domain->domain]))
|
|
->success()
|
|
->send();
|
|
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('Failed to reset records'))
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
}
|
|
}
|
|
|
|
public function applyTemplateAction(): Action
|
|
{
|
|
return Action::make('applyTemplate')
|
|
->label(__('Apply Template'))
|
|
->icon('heroicon-o-document-duplicate')
|
|
->color('gray')
|
|
->modalHeading(__('Apply Email Template'))
|
|
->modalDescription(__('This will apply the selected email DNS records immediately.'))
|
|
->modalIcon('heroicon-o-envelope')
|
|
->modalIconColor('warning')
|
|
->modalSubmitActionLabel(__('Apply Template'))
|
|
->modalWidth('lg')
|
|
->form([
|
|
Select::make('template')
|
|
->label(__('Email Provider'))
|
|
->options([
|
|
'google' => __('Google Workspace (Gmail)'),
|
|
'microsoft' => __('Microsoft 365 (Outlook)'),
|
|
'zoho' => __('Zoho Mail'),
|
|
'protonmail' => __('ProtonMail'),
|
|
'fastmail' => __('Fastmail'),
|
|
'local' => __('Local Mail Server (This Server)'),
|
|
])
|
|
->required()
|
|
->live(),
|
|
TextInput::make('verification_code')
|
|
->label(__('Domain Verification Code (optional)'))
|
|
->placeholder(__('e.g., google-site-verification=xxx')),
|
|
])
|
|
->action(function (array $data) {
|
|
$domain = Domain::find($this->selectedDomainId);
|
|
if (! $domain || $domain->user_id !== Auth::id()) {
|
|
Notification::make()->title(__('Access denied'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$domainName = $domain->domain;
|
|
$template = $data['template'];
|
|
$verificationCode = $data['verification_code'] ?? null;
|
|
|
|
$recordsToDelete = DnsRecord::where('domain_id', $this->selectedDomainId)
|
|
->where(function ($query) {
|
|
$query->where('type', 'MX')
|
|
->orWhere(function ($q) {
|
|
$q->where('type', 'A')->where('name', 'mail');
|
|
})
|
|
->orWhere(function ($q) {
|
|
$q->where('type', 'CNAME')->where('name', 'autodiscover');
|
|
})
|
|
->orWhere(function ($q) {
|
|
$q->where('type', 'TXT')
|
|
->where(function ($inner) {
|
|
$inner->where('content', 'like', '%spf%')
|
|
->orWhere('content', 'like', '%v=spf1%')
|
|
->orWhere('content', 'like', '%google-site-verification%')
|
|
->orWhere('content', 'like', '%MS=%')
|
|
->orWhere('content', 'like', '%zoho-verification%')
|
|
->orWhere('content', 'like', '%protonmail-verification%')
|
|
->orWhere('name', 'like', '%_domainkey%');
|
|
});
|
|
});
|
|
})
|
|
->pluck('id')
|
|
->toArray();
|
|
|
|
foreach ($recordsToDelete as $id) {
|
|
if (! in_array($id, $this->pendingDeletes)) {
|
|
$this->pendingDeletes[] = $id;
|
|
}
|
|
unset($this->pendingEdits[$id]);
|
|
}
|
|
|
|
$records = $this->getTemplateRecords($template, $domain, $verificationCode);
|
|
foreach ($records as $record) {
|
|
$this->queuePendingAdd($record);
|
|
}
|
|
|
|
if (! $this->hasPendingChanges()) {
|
|
Notification::make()
|
|
->title(__('No changes to apply'))
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->saveChanges(false);
|
|
|
|
Notification::make()
|
|
->title(__('Template applied'))
|
|
->body(__('Email records for :provider have been applied. Changes may take up to 48 hours to propagate.', ['provider' => ucfirst($template)]))
|
|
->success()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
protected function getTemplateRecords(string $template, Domain $domain, ?string $verificationCode): array
|
|
{
|
|
$settings = DnsSetting::getAll();
|
|
$serverIp = $domain->ip_address ?: ($settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1');
|
|
$serverIpv6 = $domain->ipv6_address ?: ($settings['default_ipv6'] ?? null);
|
|
$domainName = $domain->domain;
|
|
|
|
$records = match ($template) {
|
|
'google' => [
|
|
['name' => '@', 'type' => 'MX', 'content' => 'aspmx.l.google.com', 'ttl' => 3600, 'priority' => 1],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'alt1.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'alt2.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'alt3.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'alt4.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10],
|
|
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.google.com ~all', 'ttl' => 3600, 'priority' => null],
|
|
],
|
|
'microsoft' => [
|
|
['name' => '@', 'type' => 'MX', 'content' => str_replace('.', '-', $domainName).'.mail.protection.outlook.com', 'ttl' => 3600, 'priority' => 0],
|
|
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.protection.outlook.com ~all', 'ttl' => 3600, 'priority' => null],
|
|
['name' => 'autodiscover', 'type' => 'CNAME', 'content' => 'autodiscover.outlook.com', 'ttl' => 3600, 'priority' => null],
|
|
],
|
|
'zoho' => [
|
|
['name' => '@', 'type' => 'MX', 'content' => 'mx.zoho.com', 'ttl' => 3600, 'priority' => 10],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'mx2.zoho.com', 'ttl' => 3600, 'priority' => 20],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'mx3.zoho.com', 'ttl' => 3600, 'priority' => 50],
|
|
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:zoho.com ~all', 'ttl' => 3600, 'priority' => null],
|
|
],
|
|
'protonmail' => [
|
|
['name' => '@', 'type' => 'MX', 'content' => 'mail.protonmail.ch', 'ttl' => 3600, 'priority' => 10],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'mailsec.protonmail.ch', 'ttl' => 3600, 'priority' => 20],
|
|
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.protonmail.ch mx ~all', 'ttl' => 3600, 'priority' => null],
|
|
],
|
|
'fastmail' => [
|
|
['name' => '@', 'type' => 'MX', 'content' => 'in1-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 10],
|
|
['name' => '@', 'type' => 'MX', 'content' => 'in2-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 20],
|
|
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.messagingengine.com ~all', 'ttl' => 3600, 'priority' => null],
|
|
],
|
|
'local' => [
|
|
['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domainName, 'ttl' => 3600, 'priority' => 10],
|
|
['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
|
|
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null],
|
|
],
|
|
default => [],
|
|
};
|
|
|
|
if ($template === 'local' && ! empty($serverIpv6)) {
|
|
$records[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null];
|
|
}
|
|
|
|
if ($verificationCode) {
|
|
$records[] = ['name' => '@', 'type' => 'TXT', 'content' => $verificationCode, 'ttl' => 3600, 'priority' => null];
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
protected function syncZoneFile(string $domain): void
|
|
{
|
|
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) {
|
|
Notification::make()->title(__('Warning: Zone file sync failed'))->body($e->getMessage())->warning()->send();
|
|
}
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
$this->applyTemplateAction()
|
|
->visible(fn () => $this->selectedDomainId !== null),
|
|
$this->addRecordAction()
|
|
->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));
|
|
}
|
|
}
|