diff --git a/subdomains/database/migrations/004_add_srv_target_to_cloudflare_domains.php b/subdomains/database/migrations/004_add_srv_target_to_cloudflare_domains.php new file mode 100644 index 0000000..3cbe2a1 --- /dev/null +++ b/subdomains/database/migrations/004_add_srv_target_to_cloudflare_domains.php @@ -0,0 +1,22 @@ +string('srv_target')->nullable()->after('cloudflare_id'); + }); + } + + public function down(): void + { + Schema::table('cloudflare_domains', function (Blueprint $table) { + $table->dropColumn('srv_target'); + }); + } +}; diff --git a/subdomains/database/migrations/005_add_srv_target_to_nodes.php b/subdomains/database/migrations/005_add_srv_target_to_nodes.php new file mode 100644 index 0000000..dc30a4b --- /dev/null +++ b/subdomains/database/migrations/005_add_srv_target_to_nodes.php @@ -0,0 +1,30 @@ +dropColumn('srv_target'); + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->string('srv_target')->nullable()->after('fqdn'); + }); + } + + public function down(): void + { + Schema::table('cloudflare_domains', function (Blueprint $table) { + $table->string('srv_target')->nullable()->after('cloudflare_id'); + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->dropColumn('srv_target'); + }); + } +}; diff --git a/subdomains/lang/en/strings.php b/subdomains/lang/en/strings.php index e1cd0ea..701cbe5 100644 --- a/subdomains/lang/en/strings.php +++ b/subdomains/lang/en/strings.php @@ -3,17 +3,66 @@ return [ 'no_domains' => 'No Domains', 'domain' => 'Domain|Domains', - 'no_subdomains' => 'No Subdomains', 'subdomain' => 'Subdomain|Subdomains', + 'name' => 'Name', + 'limit' => 'Limit', 'change_limit' => 'Change Limit', 'limit_changed' => 'Limit changed', 'limit_reached' => 'Subdomain Limit Reached', + 'create_subdomain' => 'Create Subdomain', + 'subdomain_change_limit' => 'Change Subdomain Limit', + 'subdomain_limit' => 'Subdomain Limit', - 'name' => 'Name', + 'record_type' => 'Record Type', + 'srv_record' => 'SRV Record', + 'srv_record_help' => 'Enable this option to create a SRV record instead of an A or AAAA record.', + + 'set_srv_target' => 'Set SRV Target', + 'srv_target' => 'SRV Target', + 'srv_target_help' => 'The hostname that SRV records point to (for example: play.example.com).', + 'srv_target_missing' => 'SRV record is missing from node configuration.', + 'srv_target_confirmation' => 'Changing the SRV target will require all existing subdomains using SRV records to be updated to reflect the new target.', 'api_token' => 'Cloudflare API Token', 'api_token_help' => 'The token needs to have read permissions for Zone.Zone and write for Zone.Dns. For better security you can also set the "Zone Resources" to exclude certain domains and add the panel ip to the "Client IP Adress Filtering".', + + 'notifications' => [ + 'srv_target_updated_title' => 'SRV target changed successfully.', + 'srv_target_updated' => 'Existing subdomains using SRV records will need to be updated to use the new target.', + + 'cloudflare_missing_zone_title' => 'Cloudflare: Missing Zone ID', + 'cloudflare_missing_zone' => 'Cloudflare zone ID is not configured for :domain. Cannot save DNS record for :subdomain.', + + 'cloudflare_missing_srv_port_title' => 'Cloudflare: Missing SRV Port', + 'cloudflare_missing_srv_port' => 'SRV port is missing for :server.', + + 'cloudflare_missing_srv_target_title' => 'Cloudflare: Missing SRV Target', + 'cloudflare_missing_srv_target' => 'SRV target is missing from :node. ', + + 'cloudflare_record_created_title' => 'Cloudflare: Record Created', + 'cloudflare_record_created' => 'Successfully created :subdomain record of type :record', + + 'cloudflare_record_updated_title' => 'Cloudflare: Record Updated', + 'cloudflare_record_updated' => 'Successfully updated :subdomain record to :record_type', + + 'cloudflare_record_deleted_title' => 'Cloudflare: Record Deleted', + 'cloudflare_record_deleted' => 'Successfully deleted Cloudflare record for :subdomain.', + + 'cloudflare_missing_ip_title' => 'Cloudflare: Missing IP', + 'cloudflare_missing_ip' => 'Server allocation IP is missing or invalid for :subdomain. Cannot save A/AAAA record.', + + 'cloudflare_upsert_failed_title' => 'Cloudflare: Save Failed', + 'cloudflare_upsert_failed' => 'Failed to save record for :subdomain. See logs for details. Errors: :errors', + + 'cloudflare_zone_fetch_failed' => 'Failed to fetch Cloudflare Zone ID for domain: :domain', + 'cloudflare_domain_saved' => 'Successfully saved domain: :domain', + + 'cloudflare_invalid_service_record_type_title' => 'Cloudflare: Unable to determine service type', + 'cloudflare_invalid_service_record_type' => 'Unable to determine service type for SRV record of :subdomain. Please check egg is correctly tagged', + ], + + 'settings_saved' => 'Settings saved', ]; diff --git a/subdomains/plugin.json b/subdomains/plugin.json index 7ff7cbc..23e3ee6 100644 --- a/subdomains/plugin.json +++ b/subdomains/plugin.json @@ -2,7 +2,7 @@ "id": "subdomains", "name": "Subdomains", "author": "Boy132", - "version": "1.0.0", + "version": "1.1.0", "description": "Allows users to create subdomains for their servers", "category": "plugin", "url": "https://github.com/pelican-dev/plugins/tree/main/subdomains", diff --git a/subdomains/src/Enums/ServiceRecordType.php b/subdomains/src/Enums/ServiceRecordType.php new file mode 100644 index 0000000..05fa224 --- /dev/null +++ b/subdomains/src/Enums/ServiceRecordType.php @@ -0,0 +1,62 @@ +name)->title(); + } + + public static function isSupported(Server $server): bool + { + $tags = $server->egg->tags ?? []; + foreach (self::cases() as $type) { + if (in_array($type->name, $tags)) { + return true; + } + } + + return false; + } + + public static function fromServer(Server $server): ?self + { + $tags = $server->egg->tags ?? []; + + return self::fromTags($tags); + } + + /** @param string[] $tags */ + public static function fromTags(array $tags): ?self + { + foreach (self::cases() as $type) { + if (in_array($type->name, $tags)) { + return $type; + } + } + + return null; + } + + public function service(): string + { + $parts = explode('.', $this->value); + + return $parts[0]; + } + + public function protocol(): string + { + $parts = explode('.', $this->value); + + return $parts[1]; + } +} diff --git a/subdomains/src/Filament/Admin/Resources/CloudflareDomains/Pages/ManageCloudflareDomains.php b/subdomains/src/Filament/Admin/Resources/CloudflareDomains/Pages/ManageCloudflareDomains.php index 238e6fe..7a0dbf4 100644 --- a/subdomains/src/Filament/Admin/Resources/CloudflareDomains/Pages/ManageCloudflareDomains.php +++ b/subdomains/src/Filament/Admin/Resources/CloudflareDomains/Pages/ManageCloudflareDomains.php @@ -14,7 +14,8 @@ protected function getHeaderActions(): array { return [ CreateAction::make() - ->createAnother(false), + ->createAnother(false) + ->successNotification(null), ]; } } diff --git a/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php b/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php index 45de854..ab5c570 100644 --- a/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php +++ b/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php @@ -9,9 +9,9 @@ use Filament\Actions\CreateAction; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; -use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Schemas\Schema; @@ -83,8 +83,10 @@ public function form(Schema $schema): Schema ->relationship('domain', 'name') ->preload() ->searchable(), - Hidden::make('record_type') - ->default(fn () => is_ipv6($this->getOwnerRecord()->allocation->ip) ? 'AAAA' : 'A'), + Toggle::make('srv_record') + ->label(trans('subdomains::strings.srv_record')) + ->helperText(trans('subdomains::strings.srv_record_help')) + ->default(false), ]); } } diff --git a/subdomains/src/Filament/Components/Actions/SetSrvTargetAction.php b/subdomains/src/Filament/Components/Actions/SetSrvTargetAction.php new file mode 100644 index 0000000..ef1feb7 --- /dev/null +++ b/subdomains/src/Filament/Components/Actions/SetSrvTargetAction.php @@ -0,0 +1,48 @@ +label(fn () => trans('subdomains::strings.set_srv_target')); + + $this->icon('tabler-world-www'); + + $this->schema(function (?Node $node) { + return [ + TextInput::make('srv_target') + ->label(fn () => trans('subdomains::strings.srv_target')) + ->default(fn () => $node?->srv_target) + ->placeholder('play.example.com OR IPv4/IPv6 address') + ->helperText(trans('subdomains::strings.srv_target_confirmation')) + - requireConfirmation() + ->rules(['nullable', 'string', 'regex:/^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?action(function (Node $node, array $data) { + // ForceFill so we don't need to overwrite on Node::$fillable + $node->forceFill(['srv_target' => $data['srv_target']])->save(); + + Notification::make() + ->title(trans('subdomains::strings.notifications.srv_target_updated_title')) + ->body(trans('subdomains::strings.notifications.srv_target_updated')) + ->warning() + ->send(); + })->requiresConfirmation()->modalIconColor('danger')->modalDescription(trans('subdomains::strings.srv_target_confirmation')->toString()); + } +} diff --git a/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php b/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php index 584531b..5f0f790 100644 --- a/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php +++ b/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php @@ -12,9 +12,9 @@ use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; use Filament\Facades\Filament; -use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Support\Enums\IconSize; @@ -78,10 +78,17 @@ public static function table(Table $table): Table TextColumn::make('label') ->label(trans('subdomains::strings.name')) ->state(fn (Subdomain $subdomain) => $subdomain->getLabel()), + TextColumn::make('record_type') + ->label(trans('subdomains::strings.record_type')) + ->icon(fn (Subdomain $subdomain) => $subdomain->srv_record && empty($subdomain->server?->node?->srv_target) ? 'tabler-alert-triangle' : null) + ->color(fn (Subdomain $subdomain) => $subdomain->srv_record && empty($subdomain->server?->node?->srv_target) ? 'danger' : null) + ->helperText(fn (Subdomain $subdomain) => $subdomain->srv_record && empty($subdomain->server?->node?->srv_target) ? trans('subdomains::strings.srv_target_missing') : null), ]) ->recordActions([ - EditAction::make(), - DeleteAction::make(), + EditAction::make() + ->successNotification(null), + DeleteAction::make() + ->successNotification(null), ]) ->toolbarActions([ CreateAction::make() @@ -92,6 +99,7 @@ public static function table(Table $table): Table ->createAnother(false) ->hiddenLabel() ->iconButton() + ->successNotification(null) ->iconSize(IconSize::ExtraLarge), ]); } @@ -103,6 +111,7 @@ public static function form(Schema $schema): Schema TextInput::make('name') ->label(trans('subdomains::strings.name')) ->required() + ->suffix(fn (callable $get) => CloudflareDomain::find($get('domain_id'))?->name) ->unique(), Select::make('domain_id') ->label(trans_choice('subdomains::strings.domain', 1)) @@ -110,14 +119,13 @@ public static function form(Schema $schema): Schema ->required() ->relationship('domain', 'name') ->preload() + ->default(fn () => CloudflareDomain::first()?->id ?? null) ->searchable(), - Hidden::make('record_type') - ->default(function () { - /** @var Server $server */ - $server = Filament::getTenant(); - - return is_ipv6($server->allocation->ip) ? 'AAAA' : 'A'; - }), + Toggle::make('srv_record') + ->label(trans('subdomains::strings.srv_record')) + ->helperText(fn () => Filament::getTenant()->node->srv_target ? trans('subdomains::strings.srv_record_help') : trans('subdomains::strings.srv_target_missing')) + ->reactive() + ->disabled(fn () => empty(Filament::getTenant()->node->srv_target)), ]); } diff --git a/subdomains/src/Models/CloudflareDomain.php b/subdomains/src/Models/CloudflareDomain.php index dbe5dd7..db4c1ef 100644 --- a/subdomains/src/Models/CloudflareDomain.php +++ b/subdomains/src/Models/CloudflareDomain.php @@ -2,14 +2,16 @@ namespace Boy132\Subdomains\Models; +use Boy132\Subdomains\Services\CloudflareService; +use Filament\Notifications\Notification; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Support\Facades\Http; /** * @property int $id * @property string $name * @property ?string $cloudflare_id + * @property ?string $srv_target */ class CloudflareDomain extends Model { @@ -23,7 +25,24 @@ protected static function boot(): void parent::boot(); static::created(function (self $model) { - $model->fetchCloudflareId(); + $service = new CloudflareService(); + + $zoneId = $service->getZoneId($model->name); + if (!$zoneId) { + Notification::make() + ->title(trans('subdomains::strings.notifications.cloudflare_zone_fetch_failed', ['domain' => $model->name])) + ->danger() + ->send(); + } else { + Notification::make() + ->title(trans('subdomains::strings.notifications.cloudflare_domain_saved', ['domain' => $model->name])) + ->success() + ->send(); + + $model->update([ + 'cloudflare_id' => $zoneId, + ]); + } }); } diff --git a/subdomains/src/Models/Subdomain.php b/subdomains/src/Models/Subdomain.php index f8e01f4..3269537 100644 --- a/subdomains/src/Models/Subdomain.php +++ b/subdomains/src/Models/Subdomain.php @@ -3,11 +3,15 @@ namespace Boy132\Subdomains\Models; use App\Models\Server; +use Boy132\Subdomains\Enums\ServiceRecordType; +use Boy132\Subdomains\Services\CloudflareService; +use Filament\Facades\Filament; +use Filament\Notifications\Notification; use Filament\Support\Contracts\HasLabel; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; /** * @property int $id @@ -27,22 +31,67 @@ class Subdomain extends Model implements HasLabel 'cloudflare_id', 'domain_id', 'server_id', + 'srv_record', + ]; + + protected $casts = [ + 'srv_record' => 'boolean', + ]; + + protected $appends = [ + 'srv_record', ]; protected static function boot(): void { parent::boot(); - static::created(function (self $model) { - $model->createOnCloudflare(); + static::creating(function (self $model) { + // Relation does not exist yet, so we need to set it manually. + $model->setRelation('server', Filament::getTenant()); + + $registrarUpdated = $model->upsertOnCloudflare(); + if (!$registrarUpdated) { + return false; + } + + Notification::make() + ->success() + ->title(trans('subdomains::strings.notifications.cloudflare_record_created_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_record_created', ['subdomain' => $model->name . '.' . ($model->domain?->name ?? 'unknown'), 'record_type' => $model->record_type])) + ->send(); + + return true; }); - static::updated(function (self $model) { - $model->updateOnCloudflare(); + static::updating(function (self $model) { + $registrarUpdated = $model->upsertOnCloudflare(); + if (!$registrarUpdated) { + return false; + } + + Notification::make() + ->success() + ->title(trans('subdomains::strings.notifications.cloudflare_record_updated_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_record_updated', ['subdomain' => $model->name . '.' . ($model->domain?->name ?? 'unknown'), 'record_type' => $model->record_type])) + ->send(); + + return true; }); - static::deleted(function (self $model) { - $model->deleteOnCloudflare(); + static::deleting(function (self $model) { + $registrarUpdated = $model->deleteOnCloudflare(); + if (!$registrarUpdated) { + return false; + } + + Notification::make() + ->success() + ->title(trans('subdomains::strings.notifications.cloudflare_record_deleted_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_record_deleted', ['subdomain' => $model->name . '.' . ($model->domain?->name ?? 'unknown')])) + ->send(); + + return true; }); } @@ -61,57 +110,156 @@ public function getLabel(): string|Htmlable|null return $this->name . '.' . $this->domain->name; } - protected function createOnCloudflare(): void + public function getSrvRecordAttribute(): bool { - if (!$this->server->allocation || $this->server->allocation->ip === '0.0.0.0' || $this->server->allocation->ip === '::') { - return; - } + return $this->record_type === 'SRV'; + } - if (!$this->cloudflare_id) { - // @phpstan-ignore staticMethod.notFound - $response = Http::cloudflare()->post("zones/{$this->domain->cloudflare_id}/dns_records", [ - 'name' => $this->name, - 'ttl' => 120, - 'type' => $this->record_type, - 'comment' => 'Created by Pelican Subdomains plugin', - 'content' => $this->server->allocation->ip, - 'proxied' => false, - ])->json(); - - if ($response['success']) { - $dnsRecord = $response['result']; - - $this->updateQuietly([ - 'cloudflare_id' => $dnsRecord['id'], - ]); + public function setSrvRecordAttribute(bool $isSrvRecord): void + { + if ($isSrvRecord) { + $this->attributes['record_type'] = 'SRV'; + } else { + $ip = $this->server?->allocation?->ip; + if (!empty($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $this->attributes['record_type'] = 'AAAA'; + } else { + $this->attributes['record_type'] = 'A'; } } } - protected function updateOnCloudflare(): void + protected function upsertOnCloudflare(): bool { - if (!$this->server->allocation || $this->server->allocation->ip === '0.0.0.0' || $this->server->allocation->ip === '::') { - return; + $registrar = app(CloudflareService::class); + + $zoneId = $this->domain?->cloudflare_id; + if (empty($zoneId)) { + Log::warning('Cloudflare zone id missing for domain', ['domain_id' => $this->domain_id]); + + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_missing_zone_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_missing_zone', ['domain' => $this->domain?->name ?? 'unknown', 'subdomain' => $this->name . '.' . ($this->domain?->name ?? 'unknown')])) + ->send(); + + return false; } - if ($this->cloudflare_id) { - // @phpstan-ignore staticMethod.notFound - Http::cloudflare()->patch("zones/{$this->domain->cloudflare_id}/dns_records/{$this->cloudflare_id}", [ - 'name' => $this->name, - 'ttl' => 120, - 'type' => $this->record_type, - 'comment' => 'Created by Pelican Subdomains plugin', - 'content' => $this->server->allocation->ip, - 'proxied' => false, - ]); + // SRV: target comes from node, port from server allocation + if ($this->record_type === 'SRV') { + $port = $this->server->allocation?->port; + if (empty($port)) { + Log::warning('Server missing allocation with port', $this->toArray()); + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_missing_srv_port_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_missing_srv_port', ['server' => $this->server?->name ?? 'unassigned'])) + ->send(); + + return false; + } + + $serviceRecordType = ServiceRecordType::fromServer($this->server); + if (!$serviceRecordType) { + Log::warning('Unable to determine service record type for SRV record', ['server_id' => $this->server?->id, 'server' => $this->server?->name]); + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_invalid_service_record_type_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_invalid_service_record_type', ['subdomain' => $this->name . '.' . ($this->domain?->name ?? 'unknown')])) + ->send(); + + return false; + } + + if (empty($this->server?->node?->srv_target)) { + Log::warning('Node missing SRV target for SRV record', ['node_id' => $this->server->node?->id]); + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_missing_srv_target_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_missing_srv_target', ['node' => $this->server->node?->name ?? 'unknown'])) + ->send(); + + return false; + } + + $recordName = sprintf('%s.%s.%s', $serviceRecordType->service(), $serviceRecordType->protocol(), $this->name); + + $result = $registrar->upsertDnsRecord($zoneId, $recordName, 'SRV', $this->server->node->srv_target, $this->cloudflare_id, $port); + + if ($result['success'] && !empty($result['id'])) { + if ($this->cloudflare_id !== $result['id']) { + $this->updateQuietly(['cloudflare_id' => $result['id']]); + } + + return true; + } + Log::error('Failed to upsert SRV record on Cloudflare for Subdomain ID ' . $this->id, ['result' => $result]); + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_upsert_failed_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_upsert_failed', ['subdomain' => $this->name . '.' . ($this->domain?->name ?? 'unknown'), 'errors' => json_encode($result['errors'] ?? $result['body'] ?? [])])) + ->send(); + + return false; + } + + // A/AAAA + $ip = $this->server?->allocation?->ip; + if (empty($ip) || $ip === '0.0.0.0' || $ip === '::') { + Log::warning('Server allocation missing or invalid IP', ['server_id' => $this->server_id]); + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_missing_ip_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_missing_ip', ['subdomain' => $this->name . '.' . ($this->domain?->name ?? 'unknown')])) + ->send(); + + return false; } + + $result = $registrar->upsertDnsRecord($zoneId, $this->name, $this->record_type, $ip, $this->cloudflare_id, null); + + if ($result['success'] && !empty($result['id'])) { + if ($this->cloudflare_id !== $result['id']) { + $this->updateQuietly(['cloudflare_id' => $result['id']]); + } + + return true; + } + + $domainName = $this->domain?->name ?? 'unknown'; + $subdomain = sprintf('%s.%s', $this->name, $domainName); + + Log::error('Failed to upsert record on Cloudflare for Subdomain ID ' . $this->id, ['result' => $result]); + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_upsert_failed_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_upsert_failed', ['subdomain' => $subdomain, 'errors' => json_encode($result['errors'] ?? $result['body'] ?? [])])) + ->send(); + + return false; } - protected function deleteOnCloudflare(): void + protected function deleteOnCloudflare(): bool { - if ($this->cloudflare_id) { - // @phpstan-ignore staticMethod.notFound - Http::cloudflare()->delete("zones/{$this->domain->cloudflare_id}/dns_records/{$this->cloudflare_id}"); + if ($this->cloudflare_id && $this->domain && $this->domain->cloudflare_id) { + $registrar = app(CloudflareService::class); + + $result = $registrar->deleteDnsRecord($this->domain->cloudflare_id, $this->cloudflare_id); + + if (!empty($result['success']) || $result['status'] === 404) { + return true; + } + + Notification::make() + ->danger() + ->title(trans('subdomains::strings.notifications.cloudflare_delete_failed_title')) + ->body(trans('subdomains::strings.notifications.cloudflare_delete_failed', ['subdomain' => $this->name . '.' . ($this->domain?->name ?? 'unknown'), 'errors' => json_encode($result['errors'] ?? $result['body'] ?? [])])) + ->send(); + + return false; } + + return true; } } diff --git a/subdomains/src/Providers/SubdomainsPluginProvider.php b/subdomains/src/Providers/SubdomainsPluginProvider.php index 6ce1afa..948edb8 100644 --- a/subdomains/src/Providers/SubdomainsPluginProvider.php +++ b/subdomains/src/Providers/SubdomainsPluginProvider.php @@ -2,10 +2,12 @@ namespace Boy132\Subdomains\Providers; +use App\Enums\HeaderActionPosition; use App\Filament\Admin\Resources\Servers\ServerResource; use App\Models\Role; use App\Models\Server; -use Boy132\Subdomains\Filament\Admin\Resources\Users\RelationManagers\SubdomainRelationManager; +use Boy132\Subdomains\Filament\Admin\Resources\Nodes\Pages\EditNode; +use Boy132\Subdomains\Filament\Components\Actions\SetSrvTargetAction; use Boy132\Subdomains\Models\Subdomain; use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; @@ -15,6 +17,7 @@ class SubdomainsPluginProvider extends ServiceProvider public function register(): void { ServerResource::registerCustomRelations(SubdomainRelationManager::class); + EditNode::registerCustomHeaderActions(HeaderActionPosition::Before, SetSrvTargetAction::make()); Role::registerCustomDefaultPermissions('cloudflare_domain'); Role::registerCustomModelIcon('cloudflare_domain', 'tabler-world-www'); diff --git a/subdomains/src/Services/CloudflareService.php b/subdomains/src/Services/CloudflareService.php new file mode 100644 index 0000000..8e1d376 --- /dev/null +++ b/subdomains/src/Services/CloudflareService.php @@ -0,0 +1,161 @@ + $domainName]); + + return null; + } + + try { + $response = Http::cloudflare()->get('zones', [ + 'name' => $domainName, + ]); + } catch (\Throwable $e) { + Log::error('Cloudflare getZoneId request failed: ' . $e->getMessage(), ['domain' => $domainName]); + + return ['success' => false, 'errors' => ['exception' => $e->getMessage()], 'status' => $e->getCode(), 'body' => $e->getTraceAsString()]; + } + + $body = $response->json(); + + if ($response->successful() && !empty($body['result']) && count($body['result']) > 0) { + return $body['result'][0]['id'] ?? null; + } + + if (!empty($body['errors'])) { + Log::warning('Cloudflare getZoneId returned errors', ['domain' => $domainName, 'status' => $response->status(), 'errors' => $body['errors']]); + } + + return ['success' => false, 'errors' => [], 'status' => $response->status(), 'body' => $body]; + } + + public function upsertDnsRecord(string $zoneId, string $name, string $recordType, string $target, ?string $recordId = null, ?int $port = null): array + { + if (empty($zoneId) || empty($name) || empty($recordType)) { + Log::error('Cloudflare upsertDnsRecord missing required parameters', ['zone' => $zoneId, 'name' => $name, 'type' => $recordType]); + + return ['success' => false, 'id' => null, 'errors' => ['missing_parameters' => true], 'status' => 0, 'body' => null]; + } + + // Hardcoded/derived defaults + $priority = 0; + $weight = 0; + $ttl = 1; + $comment = 'Created by Pelican Subdomains plugin'; + $proxied = false; + + // Build payload based on type + if ($recordType === 'SRV') { + if (empty($port) || empty($target)) { + Log::error('Cloudflare upsert missing SRV target or port', ['zone' => $zoneId, 'name' => $name, 'type' => $recordType]); + + return ['success' => false, 'id' => null, 'errors' => ['missing_srv_target_or_port' => true], 'status' => 0, 'body' => null]; + } + + $payload = [ + 'name' => $name, + 'ttl' => $ttl, + 'type' => 'SRV', + 'comment' => $comment, + 'content' => sprintf('%d %d %d %s', $priority, $weight, $port, $target), + 'proxied' => $proxied, + 'data' => [ + 'priority' => $priority, + 'weight' => $weight, + 'port' => (int) $port, + 'target' => $target, + ], + ]; + } else { + $payload = [ + 'name' => $name, + 'ttl' => $ttl, + 'type' => $recordType, + 'comment' => $comment, + 'content' => $target, + 'proxied' => $proxied, + ]; + } + + try { + if ($recordId) { + $response = Http::cloudflare()->put("zones/{$zoneId}/dns_records/{$recordId}", $payload); + $parsed = $this->parseCloudflareHttpResponse($response); + + if ($parsed['success']) { + return $parsed; + } + + Log::error('Cloudflare update failed', ['zone' => $zoneId, 'recordId' => $recordId, 'response' => $parsed]); + + return $parsed; + } + + $response = Http::cloudflare()->post("zones/{$zoneId}/dns_records", $payload); + $parsed = $this->parseCloudflareHttpResponse($response); + + if ($parsed['success'] && !empty($parsed['id'])) { + return $parsed; + } + + Log::error('Cloudflare create failed', ['zone' => $zoneId, 'payload' => $payload, 'response' => $parsed]); + + return $parsed; + } catch (\Throwable $e) { + Log::error('Cloudflare upsert exception: ' . $e->getMessage(), ['zone' => $zoneId, 'payload' => $payload, 'status' => $e->getCode()]); + + return ['success' => false, 'errors' => ['exception' => $e->getMessage()], 'status' => $e->getCode(), 'body' => $e->getTraceAsString()]; + } + } + + public function deleteDnsRecord(string $zoneId, string $recordId): array + { + if (empty($zoneId) || empty($recordId)) { + return ['success' => false, 'errors' => ['missing_parameters' => true], 'status' => 0, 'body' => null]; + } + + try { + $response = Http::cloudflare()->delete("zones/{$zoneId}/dns_records/{$recordId}"); + + $parsed = $this->parseCloudflareHttpResponse($response); + + if ($parsed['success']) { + return $parsed; + } + + Log::error('Cloudflare delete failed', ['zone' => $zoneId, 'id' => $recordId, 'response' => $parsed]); + + return $parsed; + } catch (\Throwable $e) { + Log::error('Cloudflare delete exception: ' . $e->getMessage(), ['zone' => $zoneId, 'id' => $recordId, 'payload' => $payload, 'status' => $e->getCode()]); + + return ['success' => false, 'errors' => ['exception' => $e->getMessage()], 'status' => $e->getCode(), 'body' => $e->getTraceAsString()]; + } + } + + protected function parseCloudflareHttpResponse(Response $response): array + { + $status = $response->status(); + $body = $response->json(); + + $success = $response->successful() && ($body['success'] === true || (is_array($body['result']) && count($body['result']) > 0)); + + return [ + 'success' => $success, + 'id' => $body['result']['id'] ?? null, + 'errors' => $body['errors'] ?? [], + 'status' => $status, + 'body' => $body, + ]; + } +} diff --git a/subdomains/src/SubdomainsPlugin.php b/subdomains/src/SubdomainsPlugin.php index 17e7164..655a465 100644 --- a/subdomains/src/SubdomainsPlugin.php +++ b/subdomains/src/SubdomainsPlugin.php @@ -47,7 +47,7 @@ public function saveSettings(array $data): void ]); Notification::make() - ->title('Settings saved') + ->title(trans('subdomains::strings.notifications.settings_saved')) ->success() ->send(); }