diff --git a/subdomains/README.md b/subdomains/README.md index 77b4d0ad..59450f80 100644 --- a/subdomains/README.md +++ b/subdomains/README.md @@ -15,11 +15,20 @@ Each domain is composed of a name and an optional prefix. The name must be a val For example: when creating a subdomain `server1` on a domain with name `example.com` and prefix `abc`, the created record will be `server1.abc.example.com`. +#### Domain restrictions + +Domains can be configured to only permit subdomain creation under specific conditions: + +- For each domain you can select which DNS Record types can be created on it +- For each domain you can select the nodes on which it is enabled. Servers on unselected nodes will not have the option to use this domain. + +Leaving these fields empty will keep all record types / nodes enabled. + ## Configuration Subdomains support several different DNS Record types. Each type has different requirements before it can be created. -If a DNS Record type is not available, check whether all of it's requirements have been met. +If a DNS Record type is not available, check whether it is enabled on the domain and whether all of it's requirements have been met. ### Server primary allocations diff --git a/subdomains/database/migrations/007_add_allowed_record_types.php b/subdomains/database/migrations/007_add_allowed_record_types.php new file mode 100644 index 00000000..dd08ccf4 --- /dev/null +++ b/subdomains/database/migrations/007_add_allowed_record_types.php @@ -0,0 +1,22 @@ +json('allowed_record_types')->after('prefix')->default('["A","AAAA","CNAME","SRV"]'); + }); + } + + public function down(): void + { + Schema::table('cloudflare_domains', function (Blueprint $table) { + $table->dropColumn('allowed_record_types'); + }); + } +}; diff --git a/subdomains/database/migrations/008_make_compound_domain_unique_constraint.php b/subdomains/database/migrations/008_make_compound_domain_unique_constraint.php new file mode 100644 index 00000000..3a579988 --- /dev/null +++ b/subdomains/database/migrations/008_make_compound_domain_unique_constraint.php @@ -0,0 +1,40 @@ +whereNull('prefix')->update(['prefix' => '']); + + Schema::table('cloudflare_domains', function (Blueprint $table) { + $table->string('prefix')->default('')->nullable(false)->change(); + + $table->dropUnique(['name']); + $table->unique(['name', 'prefix']); + }); + } + + public function down(): void + { + // Deduplicate domain names + $uniqueDomainIds = DB::table('cloudflare_domains') + ->groupBy('name') + ->select(DB::raw('MIN(id) as id')) + ->pluck('id'); + + DB::table('cloudflare_domains') + ->whereNotIn('id', $uniqueDomainIds) + ->update(['name' => DB::raw("CONCAT(name, '_', prefix, '_', id)")]); + + Schema::table('cloudflare_domains', function (Blueprint $table) { + $table->dropUnique(['name', 'prefix']); + $table->unique('name'); + + $table->string('prefix')->nullable()->change(); + }); + } +}; diff --git a/subdomains/database/migrations/009_add_domain_nodes_table.php b/subdomains/database/migrations/009_add_domain_nodes_table.php new file mode 100644 index 00000000..27fa4ded --- /dev/null +++ b/subdomains/database/migrations/009_add_domain_nodes_table.php @@ -0,0 +1,28 @@ +unsignedInteger('node_id'); + $table->foreign('node_id')->references('id')->on('nodes')->cascadeOnDelete(); + + $table->unsignedInteger('cloudflare_domain_id'); + $table->foreign('cloudflare_domain_id')->references('id')->on('cloudflare_domains')->cascadeOnDelete(); + + $table->timestamps(); + + $table->unique(['node_id', 'cloudflare_domain_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('cloudflare_domain_node'); + } +}; diff --git a/subdomains/lang/de/strings.php b/subdomains/lang/de/strings.php index a3751340..c346f8bb 100644 --- a/subdomains/lang/de/strings.php +++ b/subdomains/lang/de/strings.php @@ -15,6 +15,8 @@ 'name' => 'Name', 'prefix' => 'Präfix', 'record_type' => 'Record Typ', + 'allowed_record_types' => 'Zulässige Recordtypen', + 'allowed_nodes' => 'Zulässige Nodes', 'is_synced' => 'Ist synchronisiert?', 'subdomain_target' => 'Subdomain Ziel', 'no_subdomain_target' => 'Kein Subdomain Ziel', diff --git a/subdomains/lang/en/strings.php b/subdomains/lang/en/strings.php index e8956032..0eeb6694 100644 --- a/subdomains/lang/en/strings.php +++ b/subdomains/lang/en/strings.php @@ -15,6 +15,8 @@ 'name' => 'Name', 'prefix' => 'Prefix', 'record_type' => 'Record type', + 'allowed_record_types' => 'Allowed Record types', + 'allowed_nodes' => 'Allowed Nodes', 'is_synced' => 'Is Synced?', 'subdomain_target' => 'Subdomain target', 'no_subdomain_target' => 'No Subdomain target', diff --git a/subdomains/src/Enums/RecordType.php b/subdomains/src/Enums/RecordType.php index c164e63f..118768db 100644 --- a/subdomains/src/Enums/RecordType.php +++ b/subdomains/src/Enums/RecordType.php @@ -2,7 +2,6 @@ namespace Boy132\Subdomains\Enums; -use App\Models\Server; use Filament\Support\Contracts\HasLabel; enum RecordType: string implements HasLabel @@ -16,36 +15,4 @@ public function getLabel(): string { return $this->name; } - - /** - * @return array - */ - public static function availableRecordTypes(Server $server): array - { - // Explicitly forbid ANY record creation when primary allocation is invalid - if ($server->allocation && in_array($server->allocation->ip, ['0.0.0.0', '::'])) { - return []; - } - - $types = []; - - if ($server->allocation) { - if (is_ipv6($server->allocation->ip)) { - $types[self::AAAA->name] = self::AAAA->value; - } else { - $types[self::A->name] = self::A->value; - } - } - - // @phpstan-ignore property.notFound - if ($server->node->subdomain_target) { - $types[self::CNAME->name] = self::CNAME->value; - } - - if ($server->allocation && $server->node->subdomain_target) { - $types[self::SRV->name] = self::SRV->value; - } - - return $types; - } } diff --git a/subdomains/src/Filament/Admin/Resources/CloudflareDomains/CloudflareDomainResource.php b/subdomains/src/Filament/Admin/Resources/CloudflareDomains/CloudflareDomainResource.php index cf2fc742..6cad8866 100644 --- a/subdomains/src/Filament/Admin/Resources/CloudflareDomains/CloudflareDomainResource.php +++ b/subdomains/src/Filament/Admin/Resources/CloudflareDomains/CloudflareDomainResource.php @@ -2,20 +2,26 @@ namespace Boy132\Subdomains\Filament\Admin\Resources\CloudflareDomains; +use Boy132\Subdomains\Enums\RecordType; use Boy132\Subdomains\Filament\Admin\Resources\CloudflareDomains\Pages\ManageCloudflareDomains; use Boy132\Subdomains\Models\CloudflareDomain; use Exception; use Filament\Actions\Action; use Filament\Actions\CreateAction; use Filament\Actions\DeleteAction; +use Filament\Actions\EditAction; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Resources\Resource; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Validation\Rules\Unique; class CloudflareDomainResource extends Resource { @@ -61,6 +67,12 @@ public static function table(Table $table): Table TextColumn::make('subdomains_count') ->label(trans_choice('subdomains::strings.subdomain', 2)) ->counts('subdomains'), + TextColumn::make('allowed_record_types') + ->label(trans('subdomains::strings.allowed_record_types')) + ->badge(), + TextColumn::make('nodes.name') + ->label(trans('subdomains::strings.allowed_nodes')) + ->badge(), IconColumn::make('is_synced') ->label(trans('subdomains::strings.is_synced')) ->state(fn (CloudflareDomain $domain) => !is_null($domain->cloudflare_id)) @@ -70,6 +82,7 @@ public static function table(Table $table): Table ->tooltip(fn (CloudflareDomain $domain) => $domain->cloudflare_id), ]) ->recordActions([ + EditAction::make('edit'), Action::make('sync') ->tooltip(trans('subdomains::strings.sync')) ->icon('tabler-refresh') @@ -123,9 +136,27 @@ public static function form(Schema $schema): Schema TextInput::make('name') ->label(trans('subdomains::strings.name')) ->required() - ->unique(), + ->unique(ignoreRecord: true, modifyRuleUsing: fn (Unique $rule, Get $get) => $rule + ->where('name', $get('name')) + ->where('prefix', is_null($get('prefix')) ? '' : $get('prefix'))) + ->disabledOn('edit'), TextInput::make('prefix') - ->label(trans('subdomains::strings.prefix')), + ->label(trans('subdomains::strings.prefix')) + ->unique(ignoreRecord: true, modifyRuleUsing: fn (Unique $rule, Get $get) => $rule + ->where('name', $get('name')) + ->where('prefix', is_null($get('prefix')) ? '' : $get('prefix'))) + ->disabledOn('edit') + ->dehydrateStateUsing(fn ($state) => is_null($state) ? '' : $state), + Select::make('allowed_record_types') + ->label(trans('subdomains::strings.allowed_record_types')) + ->options(RecordType::class) + ->multiple(), + Select::make('allowed_nodes') + ->label(trans('subdomains::strings.allowed_nodes')) + ->multiple() + ->searchable() + ->preload() + ->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'))), ]); } diff --git a/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php b/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php index c2ac85ec..303b7576 100644 --- a/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php +++ b/subdomains/src/Filament/Admin/Resources/Servers/RelationManagers/SubdomainRelationManager.php @@ -3,7 +3,6 @@ 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; @@ -18,6 +17,7 @@ use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Support\Exceptions\Halt; use Filament\Tables\Columns\TextColumn; @@ -84,8 +84,7 @@ public function table(Table $table): Table ->send(); }), CreateAction::make() - ->visible(fn () => CloudflareDomain::count() > 0) - ->disabled(fn () => count(RecordType::availableRecordTypes($this->getOwnerRecord())) <= 0) + ->visible(fn () => count(CloudflareDomain::availableDomains($this->getOwnerRecord())) > 0) ->createAnother(false) ->action(function (array $data, SubdomainService $service) { try { @@ -121,23 +120,26 @@ public function form(Schema $schema): Schema Select::make('domain_id') ->label(trans_choice('subdomains::strings.domain', 1)) ->disabledOn('edit') - ->hidden(fn () => CloudflareDomain::count() <= 1) - ->dehydratedWhenHidden() + ->disabled(fn () => CloudflareDomain::availableDomains($this->getOwnerRecord())->count() <= 1) + ->saved() ->required() ->selectablePlaceholder(false) - ->default(fn () => CloudflareDomain::first()?->id) ->relationship('domain', 'name') + ->options(CloudflareDomain::availableDomains($this->getOwnerRecord())->mapWithKeys(fn ($domain) => [$domain->id => $domain->nameWithPrefix()])) + ->default(CloudflareDomain::availableDomains($this->getOwnerRecord())->first()->id) ->preload() ->searchable() + ->afterStateUpdated(fn (Get $get, Set $set) => $set('record_type', CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($this->getOwnerRecord())->first())) ->live(), Select::make('record_type') ->label(trans('subdomains::strings.record_type')) ->disabledOn('edit') - ->disabled(fn () => count(RecordType::availableRecordTypes($this->getOwnerRecord())) <= 1) + ->disabled(fn (Get $get) => CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($this->getOwnerRecord())->count() <= 1) + ->saved() ->required() ->selectablePlaceholder(false) - ->options(RecordType::availableRecordTypes($this->getOwnerRecord())) - ->default(array_first(RecordType::availableRecordTypes($this->getOwnerRecord()))), + ->options(fn (Get $get) => CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($this->getOwnerRecord())->pluck('name', 'value')) + ->default(fn (Get $get) => CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($this->getOwnerRecord())->first()), ]); } } diff --git a/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php b/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php index 2cd73478..e8fb25b5 100644 --- a/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php +++ b/subdomains/src/Filament/Server/Resources/Subdomains/SubdomainResource.php @@ -5,7 +5,6 @@ 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; @@ -21,6 +20,7 @@ use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Support\Enums\IconSize; use Filament\Support\Exceptions\Halt; @@ -43,7 +43,7 @@ public static function canAccess(): bool /** @var Server $server */ $server = Filament::getTenant(); - return parent::canAccess() && CloudflareDomain::count() > 0 && count(RecordType::availableRecordTypes($server)) > 0; + return parent::canAccess() && count(CloudflareDomain::availableDomains($server)) > 0; } public static function getNavigationLabel(): string @@ -155,23 +155,25 @@ public static function form(Schema $schema): Schema Select::make('domain_id') ->label(trans_choice('subdomains::strings.domain', 1)) ->disabledOn('edit') - ->hidden(fn () => CloudflareDomain::count() <= 1) - ->dehydratedWhenHidden() + ->disabled(fn () => CloudflareDomain::availableDomains($server)->count() <= 1) + ->saved() ->required() ->selectablePlaceholder(false) - ->default(fn () => CloudflareDomain::first()?->id) - ->relationship('domain', 'name') + ->options(CloudflareDomain::availableDomains($server)->mapWithKeys(fn ($domain) => [$domain->id => $domain->nameWithPrefix()])) + ->default(CloudflareDomain::availableDomains($server)->first()->id) ->preload() ->searchable() + ->afterStateUpdated(fn (Get $get, Set $set) => $set('record_type', CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($server)->first())) ->live(), Select::make('record_type') ->label(trans('subdomains::strings.record_type')) ->disabledOn('edit') - ->disabled(fn () => count(RecordType::availableRecordTypes($server)) <= 1) + ->disabled(fn (Get $get) => CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($server)->count() <= 1) + ->saved() ->required() ->selectablePlaceholder(false) - ->options(RecordType::availableRecordTypes($server)) - ->default(array_first(RecordType::availableRecordTypes($server))), + ->options(fn (Get $get) => CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($server)->pluck('name', 'value')) + ->default(fn (Get $get) => CloudflareDomain::find($get('domain_id'))?->availableRecordTypes($server)->first()), ]); } diff --git a/subdomains/src/Models/CloudflareDomain.php b/subdomains/src/Models/CloudflareDomain.php index 90a0065f..0f3e3c08 100644 --- a/subdomains/src/Models/CloudflareDomain.php +++ b/subdomains/src/Models/CloudflareDomain.php @@ -2,16 +2,25 @@ namespace Boy132\Subdomains\Models; +use App\Models\Node; +use App\Models\Server; +use Boy132\Subdomains\Enums\RecordType; +use Boy132\Subdomains\Enums\SRVServiceType; use Exception; +use Illuminate\Database\Eloquent\Casts\AsEnumCollection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; /** * @property int $id * @property string $name - * @property ?string $prefix + * @property string $prefix * @property ?string $cloudflare_id + * @property Collection|Node[] $nodes + * @property Collection $allowed_record_types */ class CloudflareDomain extends Model { @@ -19,6 +28,7 @@ class CloudflareDomain extends Model 'name', 'prefix', 'cloudflare_id', + 'allowed_record_types', ]; protected static function boot(): void @@ -28,6 +38,17 @@ protected static function boot(): void static::created(function (self $model) { $model->fetchCloudflareId(); }); + + static::saving(function (self $model): void { + $model->allowed_record_types = $model->allowed_record_types->sort(); + }); + } + + protected function casts(): array + { + return [ + 'allowed_record_types' => AsEnumCollection::of(RecordType::class), + ]; } public function subdomains(): HasMany @@ -35,14 +56,19 @@ public function subdomains(): HasMany return $this->hasMany(Subdomain::class, 'domain_id'); } + public function nodes(): BelongsToMany + { + return $this->belongsToMany(Node::class); + } + public function nameWithPrefix(): string { - return is_null($this->prefix) ? $this->name : "$this->prefix.$this->name"; + return $this->prefix == '' ? $this->name : "$this->prefix.$this->name"; } public function prependPrefix(string $subdomain): string { - return is_null($this->prefix) ? $subdomain : "$subdomain.$this->prefix"; + return $this->prefix == '' ? $subdomain : "$subdomain.$this->prefix"; } /** @throws Exception */ @@ -69,4 +95,57 @@ public function fetchCloudflareId(): void } } } + + /** + * @return Collection + */ + public function availableRecordTypes(Server $server): Collection + { + $allocation = $server->allocation; + $subdomainTarget = $server->node->subdomain_target; // @phpstan-ignore property.notFound + $allowedRecordTypes = $this->allowed_record_types; + $allowedRecordsFilterDisabled = $allowedRecordTypes->isEmpty(); + $srvServiceType = SRVServiceType::fromServer($server); + + $types = new Collection(); + + // Explicitly forbid ANY record creation when primary allocation is invalid + if ($allocation && in_array($allocation->ip, ['0.0.0.0', '::'])) { + return $types; + } + + if (($allowedRecordsFilterDisabled || $allowedRecordTypes->contains(RecordType::A)) && $allocation && is_ipv4($allocation->ip)) { + $types->add(RecordType::A); + } + + if (($allowedRecordsFilterDisabled || $allowedRecordTypes->contains(RecordType::AAAA)) && $allocation && is_ipv6($allocation->ip)) { + $types->add(RecordType::AAAA); + } + + if (($allowedRecordsFilterDisabled || $allowedRecordTypes->contains(RecordType::CNAME)) && $subdomainTarget) { + $types->add(RecordType::CNAME); + } + + if (($allowedRecordsFilterDisabled || $allowedRecordTypes->contains(RecordType::SRV)) && $allocation && $subdomainTarget && $srvServiceType) { + $types->add(RecordType::SRV); + } + + return $types; + } + + /** + * @return Collection + */ + public static function availableDomains(Server $server): Collection + { + // Fetch all domains with this allowed node, or with no allowed nodes + $viableDomains = CloudflareDomain::query() + ->whereHas('nodes', fn ($query) => $query->whereKey($server->node->id)) + ->orWhereDoesntHave('nodes') + ->get(); + + $availableDomains = $viableDomains->filter(fn (self $item) => !$item->availableRecordTypes($server)->isEmpty()); + + return $availableDomains; + } } diff --git a/subdomains/src/Models/Subdomain.php b/subdomains/src/Models/Subdomain.php index e0874309..786064e6 100644 --- a/subdomains/src/Models/Subdomain.php +++ b/subdomains/src/Models/Subdomain.php @@ -3,6 +3,7 @@ namespace Boy132\Subdomains\Models; use App\Models\Server; +use Boy132\Subdomains\Enums\RecordType; use Boy132\Subdomains\Enums\SRVServiceType; use Exception; use Filament\Support\Contracts\HasLabel; @@ -14,7 +15,7 @@ /** * @property int $id * @property string $name - * @property string $record_type + * @property RecordType $record_type * @property ?string $cloudflare_id * @property int $domain_id * @property CloudflareDomain $domain @@ -40,6 +41,13 @@ protected static function boot(): void }); } + protected function casts(): array + { + return [ + 'record_type' => RecordType::class, + ]; + } + public function domain(): BelongsTo { return $this->belongsTo(CloudflareDomain::class, 'domain_id'); @@ -64,9 +72,18 @@ public function upsertOnCloudflare(): void } $subdomainTarget = $this->server->node->subdomain_target; // @phpstan-ignore property.notFound + $node_id = $this->server->node->id; + + if (!($this->domain->nodes->isEmpty() || $this->domain->nodes()->where('nodes.id', $node_id)->exists())) { + throw new Exception('Domain ' . $this->domain->nameWithPrefix() . ' is not permitted on node ' . $this->server->node->name); + } + + if (!($this->domain->allowed_record_types->isEmpty() || $this->domain->allowed_record_types->contains($this->record_type))) { + throw new Exception('Record type ' . $this->record_type->value . ' is not permitted on domain ' . $this->domain->nameWithPrefix()); + } switch ($this->record_type) { - case 'SRV': + case RecordType::SRV: if (!$this->server->allocation) { throw new Exception('Server has no allocation'); } @@ -85,7 +102,7 @@ public function upsertOnCloudflare(): void $payload = [ 'name' => $searchName, - 'type' => $this->record_type, + 'type' => $this->record_type->value, 'comment' => 'Created by Pelican Subdomains plugin', 'data' => [ 'port' => $this->server->allocation->port, @@ -97,7 +114,7 @@ public function upsertOnCloudflare(): void ]; break; - case 'CNAME': + case RecordType::CNAME: if (!$subdomainTarget) { throw new Exception('Node has no Subdomain target'); } @@ -106,15 +123,15 @@ public function upsertOnCloudflare(): void $payload = [ 'name' => $searchName, - 'type' => $this->record_type, + 'type' => $this->record_type->value, 'comment' => 'Created by Pelican Subdomains plugin', 'content' => $subdomainTarget, 'proxied' => false, ]; break; - case 'A': - case 'AAAA': + case RecordType::A: + case RecordType::AAAA: if (!$this->server->allocation) { throw new Exception('Server has no allocation'); } @@ -123,7 +140,7 @@ public function upsertOnCloudflare(): void $payload = [ 'name' => $searchName, - 'type' => $this->record_type, + 'type' => $this->record_type->value, 'comment' => 'Created by Pelican Subdomains plugin', 'content' => $this->server->allocation->ip, 'proxied' => false,