diff --git a/subdomains/README.md b/subdomains/README.md index 59450f80..dcc7cd46 100644 --- a/subdomains/README.md +++ b/subdomains/README.md @@ -44,6 +44,14 @@ 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. +### 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. + +This option is recommended if your node is not directly exposed to the internet. + +If your allocation Alias is not a valid IPv4 / IPv6 address, the corresponding record types will not be available. + ### SRV service types SRV Subdomains require an SRV service type. This must be configured in the egg features section. The format is `srv-` and then the service name, e.g. `srv-minecraft` or `srv-rust`. diff --git a/subdomains/database/migrations/010_add_alias_toggle_to_nodes.php b/subdomains/database/migrations/010_add_alias_toggle_to_nodes.php new file mode 100644 index 00000000..ffc86de8 --- /dev/null +++ b/subdomains/database/migrations/010_add_alias_toggle_to_nodes.php @@ -0,0 +1,22 @@ +boolean('subdomain_use_alias')->default(false)->after('subdomain_target'); + }); + } + + public function down(): void + { + Schema::table('nodes', function (Blueprint $table) { + $table->dropColumn('subdomain_use_alias'); + }); + } +}; diff --git a/subdomains/lang/de/strings.php b/subdomains/lang/de/strings.php index a4ca74fd..6ff870a2 100644 --- a/subdomains/lang/de/strings.php +++ b/subdomains/lang/de/strings.php @@ -22,6 +22,7 @@ 'is_synced' => 'Ist synchronisiert?', 'subdomain_target' => 'Subdomain Ziel', 'no_subdomain_target' => 'Kein Subdomain Ziel', + 'use_allocation_alias' => 'Allocation Alias verwenden', 'sync' => 'Synchronisieren', diff --git a/subdomains/lang/en/strings.php b/subdomains/lang/en/strings.php index 3f793bd8..d8409c5e 100644 --- a/subdomains/lang/en/strings.php +++ b/subdomains/lang/en/strings.php @@ -22,6 +22,7 @@ 'is_synced' => 'Is Synced?', 'subdomain_target' => 'Subdomain target', 'no_subdomain_target' => 'No Subdomain target', + 'use_allocation_alias' => 'Use allocation alias', 'sync' => 'Sync', diff --git a/subdomains/src/Enums/RecordType.php b/subdomains/src/Enums/RecordType.php index 118768db..aa936352 100644 --- a/subdomains/src/Enums/RecordType.php +++ b/subdomains/src/Enums/RecordType.php @@ -2,7 +2,11 @@ namespace Boy132\Subdomains\Enums; +use App\Models\Allocation; +use App\Models\Server; +use Boy132\Subdomains\Models\CloudflareDomain; use Filament\Support\Contracts\HasLabel; +use Illuminate\Support\Collection; enum RecordType: string implements HasLabel { @@ -15,4 +19,74 @@ public function getLabel(): string { return $this->name; } + + /** + * 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. + * Most important error is always returned first. + * + * @return Collection + */ + public function canBeUsedErrors(Server $server, CloudflareDomain $domain): Collection + { + $errors = new Collection(); + + $allocation = $server->allocation; + + $targetAddress = ''; + if ($allocation) { + if (in_array($allocation->ip, ['0.0.0.0', '::'])) { + $errors->add('Allocation ip is invalid (0.0.0.0 or ::)'); + } + + $targetAddress = $server->node->subdomain_use_alias ? $allocation->ip_alias : $allocation->ip; // @phpstan-ignore property.notFound + } + + $subdomainTarget = $server->node->subdomain_target; // @phpstan-ignore property.notFound + $srvServiceType = SRVServiceType::fromServer($server); + + $node_id = $server->node->id; + + // General restrictions checks + + if (!($domain->nodes->isEmpty() || $domain->nodes()->where('nodes.id', $node_id)->exists())) { + $errors->add('Domain ' . $domain->nameWithPrefix() . ' is not permitted on node ' . $server->node->name); + } + + if (!($domain->allowed_record_types->isEmpty() || $domain->allowed_record_types->contains($this))) { + $errors->add('Record type ' . $this->value . ' is not permitted on domain ' . $domain->nameWithPrefix()); + } + + // Allocation checks + + if (in_array($this, [self::A, self::AAAA, self::SRV]) && !$allocation) { + $errors->add('Server has no allocation'); + } + + if (in_array($targetAddress, ['0.0.0.0', '::'])) { + $errors->add('Allocation target address is invalid (0.0.0.0 or ::)'); + } + + if ($this == self::A && !is_ipv4($targetAddress)) { + $errors->add('Allocation target address ' . $targetAddress . ' is not a valid IPv4 address'); + } + + if ($this == self::AAAA && !is_ipv6($targetAddress)) { + $errors->add('Allocation target address ' . $targetAddress . ' is not a valid IPv6 address'); + } + + // Subdomain target checks + + if (in_array($this, [self::CNAME, self::SRV]) && !$subdomainTarget) { + $errors->add('Server has no Subdomain target'); + } + + // Other checks + + if ($this == self::SRV && !$srvServiceType) { + $errors->add('Server has no SRV service type'); + } + + return $errors; + } } diff --git a/subdomains/src/Filament/Admin/Resources/SubdomainTargets/SubdomainTargetResource.php b/subdomains/src/Filament/Admin/Resources/SubdomainTargets/SubdomainTargetResource.php index 902a7011..f27f0297 100644 --- a/subdomains/src/Filament/Admin/Resources/SubdomainTargets/SubdomainTargetResource.php +++ b/subdomains/src/Filament/Admin/Resources/SubdomainTargets/SubdomainTargetResource.php @@ -8,6 +8,7 @@ use Filament\Resources\Resource; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextInputColumn; +use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Table; class SubdomainTargetResource extends Resource @@ -45,6 +46,13 @@ public static function table(Table $table): Table 'subdomain_target' => $state, ])->save(); }), + ToggleColumn::make('subdomain_use_alias') + ->label(trans('subdomains::strings.use_allocation_alias')) + ->updateStateUsing(function (Node $node, $state) { + $node->forceFill([ + 'subdomain_use_alias' => $state, + ])->save(); + }), ]) ->emptyStateIcon('tabler-world-www') ->emptyStateDescription('') diff --git a/subdomains/src/Models/CloudflareDomain.php b/subdomains/src/Models/CloudflareDomain.php index 0f3e3c08..71e8e799 100644 --- a/subdomains/src/Models/CloudflareDomain.php +++ b/subdomains/src/Models/CloudflareDomain.php @@ -5,7 +5,6 @@ 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; @@ -66,7 +65,7 @@ public function nameWithPrefix(): string return $this->prefix == '' ? $this->name : "$this->prefix.$this->name"; } - public function prependPrefix(string $subdomain): string + public function appendPrefix(string $subdomain): string { return $this->prefix == '' ? $subdomain : "$subdomain.$this->prefix"; } @@ -97,40 +96,14 @@ public function fetchCloudflareId(): void } /** - * @return Collection + * @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 collect(RecordType::cases()) + ->filter(fn ($recordType) => $recordType + ->canBeUsedErrors($server, $this) + ->isEmpty()); } /** diff --git a/subdomains/src/Models/Subdomain.php b/subdomains/src/Models/Subdomain.php index 786064e6..0eab019e 100644 --- a/subdomains/src/Models/Subdomain.php +++ b/subdomains/src/Models/Subdomain.php @@ -66,46 +66,33 @@ public function getLabel(): string|Htmlable|null /** @throws Exception */ public function upsertOnCloudflare(): void { - // Explicitly forbid ANY record creation when primary allocation is invalid - if ($this->server->allocation && in_array($this->server->allocation->ip, ['0.0.0.0', '::'])) { - throw new Exception('Server has invalid allocation ip (0.0.0.0 or ::)'); + $errors = $this->record_type->canBeUsedErrors($this->server, $this->domain); + if ($errors->isNotEmpty()) { + throw new Exception($errors->first()); } - $subdomainTarget = $this->server->node->subdomain_target; // @phpstan-ignore property.notFound - $node_id = $this->server->node->id; + $allocation = $this->server->allocation; - 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); + $targetAddress = ''; + if ($allocation) { + $targetAddress = $this->server->node->subdomain_use_alias ? $allocation->ip_alias : $allocation->ip; // @phpstan-ignore property.notFound } - 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()); - } + $subdomainTarget = $this->server->node->subdomain_target; // @phpstan-ignore property.notFound + $srvServiceType = SRVServiceType::fromServer($this->server); + + $searchName = $this->domain->appendPrefix($this->name); switch ($this->record_type) { case RecordType::SRV: - if (!$this->server->allocation) { - throw new Exception('Server has no allocation'); - } - - if (!$subdomainTarget) { - throw new Exception('Node has no Subdomain target'); - } - - $srvServiceType = SRVServiceType::fromServer($this->server); - - if (!$srvServiceType) { - throw new Exception('Server has no SRV type'); - } - - $searchName = $this->domain->prependPrefix("$srvServiceType->value.$this->name"); + $searchName = "$srvServiceType->value.$searchName"; $payload = [ 'name' => $searchName, 'type' => $this->record_type->value, 'comment' => 'Created by Pelican Subdomains plugin', 'data' => [ - 'port' => $this->server->allocation->port, + 'port' => $allocation->port, 'priority' => 0, 'target' => $subdomainTarget, 'weight' => 0, @@ -115,12 +102,6 @@ public function upsertOnCloudflare(): void break; case RecordType::CNAME: - if (!$subdomainTarget) { - throw new Exception('Node has no Subdomain target'); - } - - $searchName = $this->domain->prependPrefix($this->name); - $payload = [ 'name' => $searchName, 'type' => $this->record_type->value, @@ -132,23 +113,17 @@ public function upsertOnCloudflare(): void case RecordType::A: case RecordType::AAAA: - if (!$this->server->allocation) { - throw new Exception('Server has no allocation'); - } - - $searchName = $this->domain->prependPrefix($this->name); - $payload = [ 'name' => $searchName, 'type' => $this->record_type->value, 'comment' => 'Created by Pelican Subdomains plugin', - 'content' => $this->server->allocation->ip, + 'content' => $targetAddress, 'proxied' => false, ]; break; default: - throw new Exception('Requested subdomain type is unsupported'); + throw new Exception('Requested subdomain type '. $this->record_type . ' is unsupported'); } // @phpstan-ignore staticMethod.notFound