Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions subdomains/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -32,18 +32,22 @@ 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

CNAME and SRV Subdomains must point to a specific Subdomain target. These can be configured for every node individually in the admin area.

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

use Boy132\Subdomains\Models\Subdomain;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('subdomains', function (Blueprint $table) {
$table->dropUnique(['name', 'domain_id']);
$table->string('record_identifier')->nullable()->after('record_type');
});

Subdomain::query()->with('server')->each(function (Subdomain $subdomain) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High migrations/011_allow_compatible_record_types.php:18

The migration fails at nullable(false)->change() whenever subdomains already contains rows, because saveQuietly() suppresses the saving listener that populates record_identifier, leaving every existing row NULL. Use save() here so the listener runs before the column is made non-nullable.

-            $subdomain->saveQuietly();
+            $subdomain->save();
🤖 Copy this AI Prompt to have your agent fix this:
In file @subdomains/database/migrations/011_allow_compatible_record_types.php around line 18:

The migration fails at `nullable(false)->change()` whenever `subdomains` already contains rows, because `saveQuietly()` suppresses the `saving` listener that populates `record_identifier`, leaving every existing row `NULL`. Use `save()` here so the listener runs before the column is made non-nullable.

Evidence trail:
Reviewed commit 025daa4; `subdomains/database/migrations/011_allow_compatible_record_types.php:12-23`; `subdomains/database/migrations/002_create_subdomains_table.php:12-26`; `subdomains/src/Models/Subdomain.php:36-46`; `subdomains/plugin.json:12-18`; https://github.com/laravel/framework/blob/v13.19.0/src/Illuminate/Database/Eloquent/Model.php ; https://laravel.com/framework/docs/eloquent#muting-events

$subdomain->record_identifier = $subdomain->record_type->uniqueIdentifier($subdomain->server);
$subdomain->saveQuietly();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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']);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
$table->dropColumn('record_identifier');
});
}
};
15 changes: 13 additions & 2 deletions subdomains/src/Enums/RecordType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ::)');
}

Expand Down Expand Up @@ -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 ::)');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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()
Expand Down
17 changes: 15 additions & 2 deletions subdomains/src/Models/Subdomain.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -129,15 +134,23 @@ 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']) {
$results = $searchResponse['result'] ?? [];

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 {
Expand Down
5 changes: 5 additions & 0 deletions subdomains/src/Services/SubdomainService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
Loading