diff --git a/subdomains/README.md b/subdomains/README.md index dcc7cd46..514b3fd4 100644 --- a/subdomains/README.md +++ b/subdomains/README.md @@ -1,6 +1,6 @@ # Subdomains (by Boy132 & HarlequinSin) -Allows users to create and manage custom subdomains (A/AAAA or SRV) for their game servers using Cloudflare DNS. +Allows users to create and manage custom subdomains (A/AAAA, CNAME, or SRV) for their game servers using Cloudflare DNS. ## Setup @@ -32,11 +32,11 @@ If a DNS Record type is not available, check whether it is enabled on the domain ### Server primary allocations -A and AAAA Subdomains will use the IP of the server's primary allocation as their target. SRV records will use the primary allocation's port as part of their target. +A and AAAA Subdomains will use the IP of the server's primary allocation as their target. SRV records require a primary allocation and use its port as part of their target. CNAME records use the node's configured subdomain target and do not use the primary allocation. -IPs such as `0.0.0.0` and `::` are considered invalid for the purposes of creating subdomains. They should be changed to proper IP addresses on which your servers can be reached. +For A and AAAA records, IPs such as `0.0.0.0` and `::` are considered invalid. They should be changed to proper IP addresses on which your servers can be reached. -**IMPORTANT: In order to create subdomains for a server, that server's primary allocation MUST have a valid IP address.** This also applies for CNAME and SRV Subdomains. +**IMPORTANT: In order to create A or AAAA subdomains for a server, that server's primary allocation MUST have a valid IP address.** SRV subdomains require a primary allocation because they use its port, but do not use its IP address. CNAME subdomains do not require an allocation. ### Subdomain targets @@ -44,6 +44,10 @@ CNAME and SRV Subdomains must point to a specific Subdomain target. These can be Note: According to [RFC2782](https://www.rfc-editor.org/info/rfc2782/), SRV records must always point to either an A or AAAA record. While some applications may handle SRV records pointing to CNAME records correctly, this can lead to undefined behavior. +### Record coexistence + +SRV records can coexist with A, AAAA, and CNAME records because they are created at a separate service-specific name. A and AAAA records can also coexist at the same name. CNAME records cannot coexist with any other record at the same name. + ### Use Allocation Alias You can specify whether servers on a specific node should use the allocation Alias instead of the allocation IP when creating A and AAAA records. diff --git a/subdomains/database/migrations/011_allow_compatible_record_types.php b/subdomains/database/migrations/011_allow_compatible_record_types.php new file mode 100644 index 00000000..88c2ea63 --- /dev/null +++ b/subdomains/database/migrations/011_allow_compatible_record_types.php @@ -0,0 +1,49 @@ +dropUnique(['name', 'domain_id']); + $table->string('record_identifier')->nullable()->after('record_type'); + }); + + Subdomain::query()->with('server')->each(function (Subdomain $subdomain) { + $subdomain->record_identifier = $subdomain->record_type->uniqueIdentifier($subdomain->server); + $subdomain->saveQuietly(); + }); + + Schema::table('subdomains', function (Blueprint $table) { + $table->string('record_identifier')->nullable(false)->change(); + $table->unique(['name', 'domain_id', 'record_identifier']); + }); + } + + public function down(): void + { + $hasDuplicateSubdomains = DB::table('subdomains') + ->select('name', 'domain_id') + ->groupBy('name', 'domain_id') + ->havingRaw('COUNT(*) > 1') + ->exists(); + + if ($hasDuplicateSubdomains) { + throw new RuntimeException( + 'Cannot roll back subdomain coexistence while multiple record types share the same name and domain. Remove the duplicate records first.', + ); + } + + Schema::table('subdomains', function (Blueprint $table) { + $table->dropUnique(['name', 'domain_id', 'record_identifier']); + $table->unique(['name', 'domain_id']); + $table->dropColumn('record_identifier'); + }); + } +}; diff --git a/subdomains/src/Enums/RecordType.php b/subdomains/src/Enums/RecordType.php index aa936352..418f0d9f 100644 --- a/subdomains/src/Enums/RecordType.php +++ b/subdomains/src/Enums/RecordType.php @@ -20,6 +20,17 @@ public function getLabel(): string return $this->name; } + public function uniqueIdentifier(Server $server): string + { + if ($this !== self::SRV) { + return $this->value; + } + + $serviceType = SRVServiceType::fromServer($server); + + return $serviceType ? $serviceType->value : 'SRV'; + } + /** * Returns errors that prevent this record type from being used with the provided server and domain. * If empty, then this record type is allowed to be used. @@ -35,7 +46,7 @@ public function canBeUsedErrors(Server $server, CloudflareDomain $domain): Colle $targetAddress = ''; if ($allocation) { - if (in_array($allocation->ip, ['0.0.0.0', '::'])) { + if (in_array($this, [self::A, self::AAAA]) && in_array($allocation->ip, ['0.0.0.0', '::'])) { $errors->add('Allocation ip is invalid (0.0.0.0 or ::)'); } @@ -63,7 +74,7 @@ public function canBeUsedErrors(Server $server, CloudflareDomain $domain): Colle $errors->add('Server has no allocation'); } - if (in_array($targetAddress, ['0.0.0.0', '::'])) { + if (in_array($this, [self::A, self::AAAA]) && in_array($targetAddress, ['0.0.0.0', '::'])) { $errors->add('Allocation target address is invalid (0.0.0.0 or ::)'); } diff --git a/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php b/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php index 303b7576..8a9b9e56 100644 --- a/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php +++ b/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php @@ -3,6 +3,7 @@ namespace Boy132\Subdomains\Filament\Admin\Resources\Servers\RelationManagers; use App\Models\Server; +use Boy132\Subdomains\Enums\RecordType; use Boy132\Subdomains\Models\CloudflareDomain; use Boy132\Subdomains\Models\Subdomain; use Boy132\Subdomains\Rules\NotOnBlacklist; @@ -22,6 +23,7 @@ use Filament\Support\Exceptions\Halt; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Validation\Rules\Unique; /** * @method Server getOwnerRecord() @@ -112,7 +114,23 @@ public function form(Schema $schema): Schema TextInput::make('name') ->label(trans('subdomains::strings.name')) ->required() - ->unique() + ->unique( + ignoreRecord: true, + modifyRuleUsing: function (Unique $rule, Get $get): Unique { + $rule->where('domain_id', $get('domain_id')); + + if (in_array($get('record_type'), [RecordType::A->value, RecordType::AAAA->value, RecordType::CNAME->value])) { + $rule->whereIn('record_type', match ($get('record_type')) { + RecordType::CNAME->value => [RecordType::A->value, RecordType::AAAA->value, RecordType::CNAME->value], + default => [$get('record_type'), RecordType::CNAME->value], + }); + } else { + $rule->where('record_identifier', RecordType::SRV->uniqueIdentifier($this->getOwnerRecord())); + } + + return $rule; + }, + ) ->alphaDash() ->rule(new NotOnBlacklist()) ->columnSpanFull() diff --git a/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php b/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php index e8fb25b5..8988b098 100644 --- a/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php +++ b/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php @@ -5,6 +5,7 @@ use App\Models\Server; use App\Traits\Filament\BlockAccessInConflict; use App\Traits\Filament\HasLimitBadge; +use Boy132\Subdomains\Enums\RecordType; use Boy132\Subdomains\Filament\Server\Resources\Subdomains\Pages\ListSubdomains; use Boy132\Subdomains\Models\CloudflareDomain; use Boy132\Subdomains\Models\Subdomain; @@ -26,6 +27,7 @@ use Filament\Support\Exceptions\Halt; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Validation\Rules\Unique; class SubdomainResource extends Resource { @@ -147,7 +149,23 @@ public static function form(Schema $schema): Schema TextInput::make('name') ->label(trans('subdomains::strings.name')) ->required() - ->unique() + ->unique( + ignoreRecord: true, + modifyRuleUsing: function (Unique $rule, Get $get) use ($server): Unique { + $rule->where('domain_id', $get('domain_id')); + + if (in_array($get('record_type'), [RecordType::A->value, RecordType::AAAA->value, RecordType::CNAME->value])) { + $rule->whereIn('record_type', match ($get('record_type')) { + RecordType::CNAME->value => [RecordType::A->value, RecordType::AAAA->value, RecordType::CNAME->value], + default => [$get('record_type'), RecordType::CNAME->value], + }); + } else { + $rule->where('record_identifier', RecordType::SRV->uniqueIdentifier($server)); + } + + return $rule; + }, + ) ->alphaDash() ->rule(new NotOnBlacklist()) ->columnSpanFull() diff --git a/subdomains/src/Models/Subdomain.php b/subdomains/src/Models/Subdomain.php index 0eab019e..dc8035c7 100644 --- a/subdomains/src/Models/Subdomain.php +++ b/subdomains/src/Models/Subdomain.php @@ -17,6 +17,7 @@ * @property string $name * @property RecordType $record_type * @property ?string $cloudflare_id + * @property string $record_identifier * @property int $domain_id * @property CloudflareDomain $domain * @property int $server_id @@ -39,6 +40,10 @@ protected static function boot(): void static::deleted(function (self $model) { $model->deleteOnCloudflare(); }); + + static::saving(function (self $model) { + $model->record_identifier = $model->record_type->uniqueIdentifier($model->server); + }); } protected function casts(): array @@ -129,7 +134,6 @@ public function upsertOnCloudflare(): void // @phpstan-ignore staticMethod.notFound $searchResponse = Http::cloudflare()->get("zones/{$this->domain->cloudflare_id}/dns_records", [ 'name' => $searchName, - 'type' => $this->record_type, ])->json(); if ($searchResponse['success']) { @@ -137,7 +141,16 @@ public function upsertOnCloudflare(): void foreach ($results as $record) { if ($record['id'] !== $this->cloudflare_id) { - throw new Exception('A subdomain with that name already exists'); + $recordType = $record['type']; + $isConflict = match ($this->record_type) { + RecordType::CNAME => true, + RecordType::A, RecordType::AAAA => in_array($recordType, [$this->record_type->value, 'CNAME'], true), + RecordType::SRV => in_array($recordType, ['CNAME', 'SRV']), + }; + + if ($isConflict) { + throw new Exception("A $recordType record already exists for this subdomain"); + } } } } else { diff --git a/subdomains/src/Services/SubdomainService.php b/subdomains/src/Services/SubdomainService.php index 3592993b..d561a81c 100644 --- a/subdomains/src/Services/SubdomainService.php +++ b/subdomains/src/Services/SubdomainService.php @@ -15,10 +15,12 @@ class SubdomainService public function handle(array $data, ?Subdomain $subdomain = null): Subdomain { $newSubdomain = true; + $originalAttributes = []; if (is_null($subdomain)) { $subdomain = Subdomain::create($data); } else { + $originalAttributes = $subdomain->getRawOriginal(); $subdomain->update($data); $newSubdomain = false; } @@ -30,6 +32,9 @@ public function handle(array $data, ?Subdomain $subdomain = null): Subdomain } catch (Exception $exception) { if ($newSubdomain) { $subdomain->delete(); + } else { + $subdomain->setRawAttributes($originalAttributes); + $subdomain->saveQuietly(); } throw $exception;