2026-08-03 21:11:54 +00:00
< ? php
namespace App\Livewire\Project\Service ;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect ;
use App\Models\Server ;
use App\Models\Service ;
use App\Models\ServiceApplication ;
use App\Support\ValidationPatterns ;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests ;
use Illuminate\Support\Collection ;
use Illuminate\Support\Facades\DB ;
use Livewire\Component ;
class Domains extends Component
{
use AuthorizesRequests ;
use InteractsWithCloudflareDomainConnect ;
public Service $service ;
/** @var array<int, array{id: int, name: string, image: ?string, required_port: ?int}> */
public array $serviceApps = [];
/**
* Per service - application www / non - www redirect direction .
*
* @ var array < int | string , string >
*/
public array $serviceRedirects = [];
/** Service application id when a pending domain conflict belongs to setServiceRedirect. */
public ? int $pendingRedirectServiceApplicationId = null ;
/** @var array<int, array<string, mixed>> */
public array $domainRows = [];
public ? int $newServiceApplicationId = null ;
public string $newDomain = '' ;
public ? int $editingIndex = null ;
public string $editingDomain = '' ;
public ? int $editingServiceApplicationId = null ;
public bool $showEditDomainModal = false ;
public bool $forceSaveDomains = false ;
public bool $forceSaveDns = false ;
public bool $addDomainDnsFailed = false ;
public string $addDomainDnsMessage = '' ;
public bool $forceSaveEditDns = false ;
public bool $editDomainDnsFailed = false ;
public string $editDomainDnsMessage = '' ;
public ? int $forceAddSuggestedIndex = null ;
public array $domainConflicts = [];
public bool $showDomainConflictModal = false ;
public bool $showPortWarningModal = false ;
public bool $forceRemovePort = false ;
public ? int $requiredPort = null ;
public bool $isCheckingDns = false ;
public bool $dnsValidationEnabled = true ;
public ? string $serverIp = null ;
public ? string $serverIpConfigured = null ;
/** Pending save payload after conflict/port confirmation */
public ? string $pendingAction = null ;
protected $listeners = [
'refresh' => 'refreshDomains' ,
'refreshServices' => 'refreshDomains' ,
'confirmDomainUsage' ,
];
protected function rules () : array
{
return [
'newDomain' => ValidationPatterns :: applicationDomainRules (),
'editingDomain' => ValidationPatterns :: applicationDomainRules (),
'newServiceApplicationId' => 'nullable|integer' ,
'serviceRedirects' => 'array' ,
'serviceRedirects.*' => 'string|in:both,www,non-www' ,
];
}
public function mount () : void
{
$this -> authorize ( 'view' , $this -> service );
$this -> loadDomainState ();
}
public function refreshDomains () : void
{
$this -> service -> refresh ();
$this -> service -> load ([ 'applications' , 'server' ]);
$this -> loadDomainState ();
}
public function loadDomainState () : void
{
$this -> service -> loadMissing ([ 'applications' , 'server' ]);
$settings = instanceSettings ();
$this -> dnsValidationEnabled = ( bool ) data_get ( $settings , 'is_dns_validation_enabled' , true );
$server = $this -> service -> server ;
if ( $server ) {
if ( $server -> id === 0 ) {
$configured = data_get ( $settings , 'public_ipv4' )
? : data_get ( $settings , 'public_ipv6' )
? : $server -> ip ;
} else {
$configured = $server -> ip ;
}
$resolved = resolveServerIpAddress ( is_string ( $configured ) ? $configured : null );
$this -> serverIpConfigured = $resolved [ 'configured' ];
$this -> serverIp = $resolved [ 'ip' ] ? ? $resolved [ 'configured' ];
} else {
$this -> serverIp = null ;
$this -> serverIpConfigured = null ;
}
$this -> serviceApps = $this -> service -> applications
-> sortBy ( fn ( ServiceApplication $app ) => strtolower ( $app -> human_name ? : $app -> name ))
-> values ()
-> map ( fn ( ServiceApplication $app ) => [
'id' => $app -> id ,
'name' => $app -> human_name ? : $app -> name ,
'image' => $app -> image ,
'required_port' => $app -> getRequiredPort (),
])
-> all ();
$this -> serviceRedirects = [];
foreach ( $this -> service -> applications as $app ) {
$this -> serviceRedirects [ $app -> id ] = $this -> normalizeRedirect ( $app -> redirect ? ? null );
}
if ( $this -> newServiceApplicationId === null && count ( $this -> serviceApps ) > 0 ) {
$this -> newServiceApplicationId = $this -> serviceApps [ 0 ][ 'id' ];
}
2026-08-05 11:50:11 +00:00
// Do not auto-promote www/non-www pairs here: load/refresh also runs after
// removeDomain, and re-adding counterparts would undo intentional deletes.
// Pairs are still ensured on setServiceRedirect, addDomain, etc.
2026-08-03 21:11:54 +00:00
$this -> domainRows = $this -> buildDomainRows ();
}
protected function normalizeRedirect ( ? string $redirect ) : string
{
return in_array ( $redirect , [ 'www' , 'non-www' , 'both' ], true ) ? $redirect : 'both' ;
}
protected function serviceRedirectFor ( ? int $serviceApplicationId ) : string
{
if ( ! $serviceApplicationId ) {
return 'both' ;
}
return $this -> normalizeRedirect ( $this -> serviceRedirects [ $serviceApplicationId ] ? ? null );
}
2026-08-06 11:18:46 +00:00
public function updatedServiceRedirects ( string $redirect , int | string $serviceApplicationId ) : void
{
$this -> updateServiceRedirect (( int ) $serviceApplicationId , $redirect );
}
2026-08-03 21:11:54 +00:00
/**
* @ return array < int , array < string , mixed >>
*/
protected function buildDomainRows () : array
{
$rows = [];
foreach ( $this -> service -> applications -> sortBy ( fn ( ServiceApplication $app ) => strtolower ( $app -> human_name ? : $app -> name )) as $app ) {
$stored = is_array ( $app -> domain_dns_statuses ) ? $app -> domain_dns_statuses : [];
$configured = [];
foreach ( $this -> splitDomains ( $app -> fqdn ) as $url ) {
$row = $this -> domainRowFromStored ( $url , $app , $stored );
$rows [] = $row ;
$configured [] = $row ;
}
foreach ( $this -> buildSuggestedWwwRows ( $configured , $app , $stored ) as $suggested ) {
$rows [] = $suggested ;
}
}
2026-08-05 11:50:11 +00:00
return collect ( $rows )
-> sortBy ( fn ( array $row ) : int => ( $row [ 'dns_status' ] ? ? null ) === 'failed' ? 0 : 1 )
-> values ()
-> all ();
2026-08-03 21:11:54 +00:00
}
/**
* @ param array < string , mixed > $stored
* @ return array < string , mixed >
*/
protected function domainRowFromStored ( string $url , ServiceApplication $app , array $stored ) : array
{
$entry = $stored [ $url ] ? ? null ;
$displayName = $app -> human_name ? : $app -> name ;
if ( is_array ( $entry ) && filled ( data_get ( $entry , 'status' ))) {
return [
'service_application_id' => $app -> id ,
'service_name' => $displayName ,
'service_image' => $app -> image ,
'url' => $url ,
'dns_status' => ( string ) data_get ( $entry , 'status' , 'pending' ),
'dns_message' => ( string ) data_get ( $entry , 'message' , 'Not checked yet.' ),
'expected_ip' => data_get ( $entry , 'expected_ip' ) ? : $this -> serverIp ,
'checked_at' => data_get ( $entry , 'checked_at' ),
'is_suggested' => false ,
'suggested_for' => null ,
'suggestion_label' => null ,
'needs_force_add' => false ,
];
}
return [
'service_application_id' => $app -> id ,
'service_name' => $displayName ,
'service_image' => $app -> image ,
'url' => $url ,
'dns_status' => 'pending' ,
'dns_message' => 'Not checked yet.' ,
'expected_ip' => $this -> serverIp ,
'checked_at' => null ,
'is_suggested' => false ,
'suggested_for' => null ,
'suggestion_label' => null ,
'needs_force_add' => false ,
];
}
/**
* @ param array < int , array < string , mixed >> $configuredRows
* @ param array < string , mixed > $stored
* @ return array < int , array < string , mixed >>
*/
protected function buildSuggestedWwwRows ( array $configuredRows , ServiceApplication $app , array $stored ) : array
{
$knownHosts = [];
foreach ( $configuredRows as $row ) {
$host = $this -> domainHost (( string ) ( $row [ 'url' ] ? ? '' ));
if ( $host !== null ) {
$knownHosts [ strtolower ( $host )] = true ;
}
}
$suggested = [];
foreach ( $configuredRows as $row ) {
$url = ( string ) ( $row [ 'url' ] ? ? '' );
$counterpart = $this -> wwwCounterpartUrl ( $url );
if ( $counterpart === null ) {
continue ;
}
$counterpartHost = $this -> domainHost ( $counterpart );
if ( $counterpartHost === null ) {
continue ;
}
$hostKey = strtolower ( $counterpartHost );
if ( isset ( $knownHosts [ $hostKey ])) {
continue ;
}
$knownHosts [ $hostKey ] = true ;
$base = $this -> domainRowFromStored ( $counterpart , $app , $stored );
$isWww = str_starts_with ( $hostKey , 'www.' );
$meta = $this -> suggestedDomainMeta ( $isWww , $this -> serviceRedirectFor ( $app -> id ));
$base [ 'is_suggested' ] = true ;
$base [ 'suggested_for' ] = $url ;
2026-08-05 11:50:11 +00:00
$base [ 'suggestion_label' ] = null ;
2026-08-03 21:11:54 +00:00
$base [ 'suggestion_role' ] = $meta [ 'role' ];
$base [ 'needs_force_add' ] = false ;
2026-08-05 11:50:11 +00:00
$base [ 'dns_message' ] = $meta [ 'pending_message' ];
2026-08-03 21:11:54 +00:00
$suggested [] = $base ;
}
return $suggested ;
}
/**
* @ return array < int , string >
*/
protected function splitDomains ( ? string $value ) : array
{
if ( blank ( $value )) {
return [];
}
return collect ( explode ( ',' , $value ))
-> map ( fn ( $domain ) => trim (( string ) $domain ))
-> filter ()
-> values ()
-> all ();
}
public function dnsTargetLabel () : ? string
{
if ( blank ( $this -> serverIp )) {
return $this -> serverIpConfigured ;
}
if (
filled ( $this -> serverIpConfigured )
&& $this -> serverIpConfigured !== $this -> serverIp
&& filter_var ( $this -> serverIpConfigured , FILTER_VALIDATE_IP ) === false
) {
return " { $this -> serverIp } ( { $this -> serverIpConfigured } ) " ;
}
return $this -> serverIp ;
}
protected function authorizeUpdateForDomainConnect () : void
{
$this -> authorize ( 'update' , $this -> service );
}
public function checkAllDns () : void
{
$this -> authorize ( 'update' , $this -> service );
$this -> isCheckingDns = true ;
try {
$server = $this -> service -> server ;
$skipDns = ! $this -> dnsValidationEnabled || ! $server ;
foreach ( $this -> domainRows as $index => $row ) {
if ( $skipDns ) {
$this -> domainRows [ $index ][ 'dns_status' ] = 'skipped' ;
$this -> domainRows [ $index ][ 'dns_message' ] = ! $this -> dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: 'No server available for DNS validation.' ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
continue ;
}
$this -> applyDnsStatus ( $index , $row [ 'url' ], $server );
}
$this -> persistAllDomainDnsStatuses ();
} finally {
$this -> isCheckingDns = false ;
}
}
public function checkDomainDns ( int $index ) : void
{
$this -> authorize ( 'update' , $this -> service );
if ( ! isset ( $this -> domainRows [ $index ])) {
return ;
}
$server = $this -> service -> server ;
if ( ! $server || ! $this -> dnsValidationEnabled ) {
$this -> domainRows [ $index ][ 'dns_status' ] = 'skipped' ;
$this -> domainRows [ $index ][ 'dns_message' ] = 'DNS check skipped.' ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
2026-08-05 11:50:11 +00:00
$this -> decorateSuggestedDomainAfterDnsCheck ( $index );
2026-08-03 21:11:54 +00:00
$this -> persistAllDomainDnsStatuses ();
return ;
}
$this -> applyDnsStatus ( $index , $this -> domainRows [ $index ][ 'url' ], $server );
$this -> persistAllDomainDnsStatuses ();
}
protected function applyDnsStatus ( int $index , string $url , Server $server ) : void
{
$target = $this -> dnsTargetLabel ();
try {
$isValid = validateDNSEntry ( $url , $server );
if ( $isValid ) {
$this -> domainRows [ $index ][ 'dns_status' ] = 'ok' ;
$this -> domainRows [ $index ][ 'dns_message' ] = $target
? " DNS points to { $target } (or Cloudflare). "
: 'DNS looks correct.' ;
} else {
$this -> domainRows [ $index ][ 'dns_status' ] = 'failed' ;
$this -> domainRows [ $index ][ 'dns_message' ] = dnsMismatchGuidanceMessage ( $target , $this -> serverIp );
}
} catch ( \Throwable ) {
$this -> domainRows [ $index ][ 'dns_status' ] = 'failed' ;
$this -> domainRows [ $index ][ 'dns_message' ] = 'Could not validate DNS for this domain.' ;
}
$this -> domainRows [ $index ][ 'expected_ip' ] = $this -> serverIp ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
2026-08-05 11:50:11 +00:00
$this -> decorateSuggestedDomainAfterDnsCheck ( $index );
}
/**
* Keep suggested - row copy short after a DNS check ( no role badge ) .
*/
protected function decorateSuggestedDomainAfterDnsCheck ( int $index ) : void
{
if ( ! ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false )) {
return ;
}
$isWww = str_starts_with ( strtolower (( string ) $this -> domainHost (( string ) $this -> domainRows [ $index ][ 'url' ])), 'www.' );
$appId = ( int ) ( $this -> domainRows [ $index ][ 'service_application_id' ] ? ? 0 );
$meta = $this -> suggestedDomainMeta ( $isWww , $this -> serviceRedirectFor ( $appId > 0 ? $appId : null ));
$this -> domainRows [ $index ][ 'dns_message' ] = $meta [ 'pending_message' ];
$this -> domainRows [ $index ][ 'suggestion_label' ] = null ;
$this -> domainRows [ $index ][ 'suggestion_role' ] = $meta [ 'role' ];
2026-08-03 21:11:54 +00:00
}
protected function persistAllDomainDnsStatuses () : void
{
$byApp = [];
foreach ( $this -> domainRows as $row ) {
$appId = ( int ) ( $row [ 'service_application_id' ] ? ? 0 );
$url = $row [ 'url' ] ? ? null ;
if ( $appId === 0 || ! is_string ( $url ) || $url === '' ) {
continue ;
}
$status = $row [ 'dns_status' ] ? ? 'pending' ;
if ( $status === 'pending' ) {
$app = $this -> service -> applications -> firstWhere ( 'id' , $appId );
$existing = is_array ( $app ? -> domain_dns_statuses ) ? ( $app -> domain_dns_statuses [ $url ] ? ? null ) : null ;
if ( is_array ( $existing ) && filled ( data_get ( $existing , 'status' ))) {
$byApp [ $appId ][ $url ] = $existing ;
}
continue ;
}
$byApp [ $appId ][ $url ] = [
'status' => $status ,
'message' => ( string ) ( $row [ 'dns_message' ] ? ? '' ),
'expected_ip' => $row [ 'expected_ip' ] ? ? $this -> serverIp ,
'checked_at' => $row [ 'checked_at' ] ? ? now () -> toIso8601String (),
];
}
foreach ( $this -> service -> applications as $app ) {
$statuses = $byApp [ $app -> id ] ? ? [];
// Keep only URLs that still exist (configured or suggested currently shown)
$currentUrls = collect ( $this -> domainRows )
-> where ( 'service_application_id' , $app -> id )
-> pluck ( 'url' )
-> all ();
$statuses = array_intersect_key ( $statuses , array_flip ( $currentUrls ));
$app -> domain_dns_statuses = $statuses === [] ? null : $statuses ;
$app -> save ();
}
$this -> service -> load ( 'applications' );
}
protected function pruneDomainDnsStatusesToCurrentDomains () : void
{
$this -> refreshDomains ();
$this -> persistAllDomainDnsStatuses ();
}
public function updatedNewDomain () : void
{
$this -> addDomainDnsFailed = false ;
$this -> addDomainDnsMessage = '' ;
$this -> forceSaveDns = false ;
}
public function updatedEditingDomain () : void
{
$this -> editDomainDnsFailed = false ;
$this -> editDomainDnsMessage = '' ;
$this -> forceSaveEditDns = false ;
}
public function confirmAddDomainDespiteDns () : void
{
$this -> forceSaveDns = true ;
$this -> addDomain ();
}
public function confirmUpdateDomainDespiteDns () : void
{
$this -> forceSaveEditDns = true ;
$this -> updateDomain ();
}
public function confirmDomainUsage () : void
{
$this -> forceSaveDomains = true ;
$this -> showDomainConflictModal = false ;
if ( $this -> pendingAction === 'update' ) {
$this -> updateDomain ();
return ;
}
if ( $this -> pendingAction === 'suggested' ) {
$this -> addSuggestedDomain (( int ) ( $this -> editingIndex ? ? - 1 ));
return ;
}
if ( $this -> pendingAction === 'redirect' && $this -> pendingRedirectServiceApplicationId ) {
$this -> setServiceRedirect (( int ) $this -> pendingRedirectServiceApplicationId );
return ;
}
$this -> addDomain ();
}
/**
* Labels / copy for a suggested www or non - www host based on Direction .
*
* @ return array { label : string , role : string , pending_message : string , dns_suffix : string }
*/
protected function suggestedDomainMeta ( bool $suggestedIsWww , ? string $redirectOverride = null ) : array
{
2026-08-05 11:50:11 +00:00
$pendingMessage = 'Not configured yet.' ;
2026-08-03 21:11:54 +00:00
$redirect = $this -> normalizeRedirect ( $redirectOverride );
return match ( $redirect ) {
'www' => $suggestedIsWww
? [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · canonical www' ,
2026-08-03 21:11:54 +00:00
'role' => 'canonical' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
]
: [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · redirect source' ,
2026-08-03 21:11:54 +00:00
'role' => 'redirect_source' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
],
'non-www' => $suggestedIsWww
? [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · redirect source' ,
2026-08-03 21:11:54 +00:00
'role' => 'redirect_source' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
]
: [
2026-08-05 11:50:11 +00:00
'label' => 'Not added · canonical non-www' ,
2026-08-03 21:11:54 +00:00
'role' => 'canonical' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
'dns_suffix' => '' ,
2026-08-03 21:11:54 +00:00
],
default => [
2026-08-05 11:50:11 +00:00
'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www' ,
2026-08-03 21:11:54 +00:00
'role' => 'pair' ,
2026-08-05 11:50:11 +00:00
'pending_message' => $pendingMessage ,
2026-08-03 21:11:54 +00:00
'dns_suffix' => '' ,
],
};
}
2026-08-06 11:18:46 +00:00
public function updateServiceRedirect ( int $serviceApplicationId , string $redirect ) : void
{
$this -> serviceRedirects [ $serviceApplicationId ] = $redirect ;
$this -> setServiceRedirect ( $serviceApplicationId );
}
2026-08-03 21:11:54 +00:00
/**
* @ param mixed ... $modalArgs Extra args from modal - confirmation ( password , etc . )
*/
public function setServiceRedirect ( int $serviceApplicationId , mixed ... $modalArgs ) : void
{
try {
$this -> authorize ( 'update' , $this -> service );
$app = $this -> findServiceApp ( $serviceApplicationId );
if ( ! $app ) {
$this -> dispatch ( 'error' , 'Service application not found.' );
return ;
}
$this -> validateOnly ( " serviceRedirects. { $serviceApplicationId } " );
$redirect = $this -> normalizeRedirect ( $this -> serviceRedirects [ $serviceApplicationId ] ? ? null );
$this -> serviceRedirects [ $serviceApplicationId ] = $redirect ;
$this -> pendingRedirectServiceApplicationId = $serviceApplicationId ;
$saved = DB :: transaction ( function () use ( $app , $redirect ) : bool {
// Promote the optional www/non-www suggestion to a real domain for redirects.
if ( in_array ( $redirect , [ 'www' , 'non-www' ], true )) {
if ( ! $this -> ensureWwwNonWwwPairsConfigured ( $app )) {
return false ;
}
$app -> refresh ();
}
$domains = collect ( $this -> splitDomains ( $app -> fqdn ));
if ( ! $this -> assertRedirectDomainsPresent ( $redirect , $domains )) {
return false ;
}
$app -> redirect = $redirect ;
$app -> save ();
updateCompose ( $app );
$this -> service -> parse ();
return true ;
});
if ( ! $saved ) {
return ;
}
$this -> pendingAction = null ;
$this -> pendingRedirectServiceApplicationId = null ;
$this -> forceSaveDomains = false ;
$this -> forceRemovePort = false ;
$this -> dispatch ( 'success' , 'Redirect updated.' );
$this -> dispatch ( 'configurationChanged' );
$this -> pruneDomainDnsStatusesToCurrentDomains ();
2026-08-06 11:18:46 +00:00
$this -> refreshDomains ();
2026-08-03 21:11:54 +00:00
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
/**
* @ param Collection < int , string > $domains
*/
protected function assertRedirectDomainsPresent ( string $redirect , Collection $domains ) : bool
{
if ( ! in_array ( $redirect , [ 'www' , 'non-www' ], true )) {
return true ;
}
$hasWww = $domains -> filter (
fn ( $fqdn ) => str_starts_with ( strtolower (( string ) $this -> domainHost (( string ) $fqdn )), 'www.' )
) -> count ();
$hasNonWww = $domains -> filter (
function ( $fqdn ) {
$host = strtolower (( string ) $this -> domainHost (( string ) $fqdn ));
return $host !== '' && ! str_starts_with ( $host , 'www.' );
}
) -> count ();
$dnsHint = dnsMismatchGuidanceMessage (
$this -> dnsTargetLabel () ? ? $this -> serverIp ,
$this -> serverIp ,
);
// Redirects need both hosts: canonical target + source the proxy redirects from.
if ( $hasWww === 0 || $hasNonWww === 0 ) {
$missing = $hasWww === 0 ? 'www' : 'non-www' ;
$this -> dispatch (
'error' ,
" Redirect requires both www and non-www domains, but the { $missing } host could not be added automatically (e.g. only IP/sslip hosts).<br><br>Please add the { $missing } domain manually ( { $dnsHint } ). "
);
return false ;
}
return true ;
}
2026-08-05 11:50:11 +00:00
/**
* When saved redirect is www / non - www , ensure missing counterparts exist as real domains .
*
* @ return array < int , string > newly added domain URLs
*/
protected function syncRedirectDomainPairs ( ? ServiceApplication $app = null ) : array
{
$user = auth () -> user ();
if ( $user === null || ! $user -> can ( 'update' , $this -> service )) {
return [];
}
if ( $app === null ) {
$added = [];
foreach ( $this -> service -> applications as $serviceApp ) {
$added = array_merge ( $added , $this -> syncRedirectDomainPairs ( $serviceApp ));
}
return array_values ( array_unique ( $added ));
}
$redirect = $this -> normalizeRedirect ( $app -> redirect ? ? null );
if ( ! in_array ( $redirect , [ 'www' , 'non-www' ], true )) {
return [];
}
$before = collect ( $this -> splitDomains ( $app -> fqdn )) -> all ();
if ( ! $this -> ensureWwwNonWwwPairsConfigured ( $app )) {
return [];
}
$app -> refresh ();
$after = collect ( $this -> splitDomains ( $app -> fqdn ));
return $after -> reject ( fn ( string $url ) => in_array ( $url , $before , true )) -> values () -> all ();
}
2026-08-03 21:11:54 +00:00
/**
* Persist missing www / non - www counterparts as normal domains ( not suggestions ) .
*
* @ return bool false when save was blocked ( e . g . domain conflict modal shown )
*/
protected function ensureWwwNonWwwPairsConfigured ( ServiceApplication $app ) : bool
{
$current = collect ( $this -> splitDomains ( $app -> fqdn ));
$knownHosts = [];
foreach ( $current as $url ) {
$host = $this -> domainHost ( $url );
if ( $host !== null ) {
$knownHosts [ strtolower ( $host )] = true ;
}
}
$toAdd = collect ();
foreach ( $current as $url ) {
$counterpart = $this -> wwwCounterpartUrl ( $url , forRedirectPairing : true );
if ( $counterpart === null ) {
continue ;
}
$counterpartHost = $this -> domainHost ( $counterpart );
if ( $counterpartHost === null ) {
continue ;
}
$hostKey = strtolower ( $counterpartHost );
if ( isset ( $knownHosts [ $hostKey ])) {
continue ;
}
$knownHosts [ $hostKey ] = true ;
$toAdd -> push ( $counterpart );
}
if ( $toAdd -> isEmpty ()) {
return true ;
}
$merged = $current -> merge ( $toAdd ) -> unique () -> values ();
$this -> pendingAction = 'redirect' ;
$this -> pendingRedirectServiceApplicationId = $app -> id ;
// Skip DNS: pairing for redirects must still be configured even when DNS is not ready.
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainListForApp ( $app , $merged )) {
2026-08-03 21:11:54 +00:00
return false ;
}
2026-08-05 11:50:11 +00:00
$this -> pendingAction = null ;
$this -> pendingRedirectServiceApplicationId = null ;
2026-08-03 21:11:54 +00:00
return true ;
}
public function confirmRemovePort () : void
{
$this -> forceRemovePort = true ;
$this -> showPortWarningModal = false ;
if ( $this -> pendingAction === 'update' ) {
$this -> updateDomain ();
return ;
}
if ( $this -> pendingAction === 'suggested' ) {
$this -> addSuggestedDomain (( int ) ( $this -> editingIndex ? ? - 1 ));
return ;
}
$this -> addDomain ();
}
public function cancelRemovePort () : void
{
$this -> showPortWarningModal = false ;
$this -> forceSaveDomains = false ;
$this -> forceRemovePort = false ;
$this -> pendingAction = null ;
}
public function addDomain () : void
{
try {
$this -> authorize ( 'update' , $this -> service );
$this -> validateOnly ( 'newDomain' );
$app = $this -> findServiceApp ( $this -> newServiceApplicationId );
if ( ! $app ) {
$this -> dispatch ( 'error' , 'Select a service application.' );
return ;
}
$normalized = ValidationPatterns :: normalizeApplicationDomains ( $this -> newDomain );
if ( blank ( $normalized )) {
$this -> addError ( 'newDomain' , 'Please enter a valid domain URL.' );
return ;
}
$newUrls = $this -> splitDomains ( $normalized );
2026-08-06 11:18:46 +00:00
$pairedUrls = in_array ( $this -> normalizeRedirect ( $app -> redirect ), [ 'www' , 'non-www' ], true )
? collect ( $newUrls )
-> map ( fn ( string $url ) => $this -> wwwCounterpartUrl ( $url ))
-> filter ()
-> values ()
-> all ()
: [];
2026-08-03 21:11:54 +00:00
$current = collect ( $this -> splitDomains ( $app -> fqdn ));
foreach ( $newUrls as $url ) {
if ( $current -> contains ( $url )) {
$this -> addError ( 'newDomain' , " Domain { $url } is already configured for this service. " );
return ;
}
}
if ( ! $this -> forceSaveDns && $this -> shouldValidateDns ()) {
$dnsFailure = $this -> findDnsFailureMessage ( $newUrls );
if ( $dnsFailure !== null ) {
$this -> addDomainDnsFailed = true ;
$this -> addDomainDnsMessage = $dnsFailure ;
return ;
}
}
2026-08-05 11:50:11 +00:00
$merged = $current -> merge ( $newUrls ) -> merge ( $pairedUrls ) -> unique () -> values ();
2026-08-03 21:11:54 +00:00
$this -> pendingAction = 'add' ;
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainListForApp ( $app , $merged )) {
2026-08-03 21:11:54 +00:00
return ;
}
$this -> newDomain = '' ;
$this -> addDomainDnsFailed = false ;
$this -> addDomainDnsMessage = '' ;
$this -> forceSaveDns = false ;
$this -> forceSaveDomains = false ;
$this -> forceRemovePort = false ;
$this -> pendingAction = null ;
$this -> dispatch ( 'close-modal' );
$this -> dispatch ( 'success' , 'Domain added.' );
$this -> refreshDomains ();
2026-08-05 11:50:11 +00:00
$this -> checkUrlsDns ( array_values ( array_unique ( array_merge ( $newUrls , $pairedUrls ))), ( int ) $app -> id );
2026-08-03 21:11:54 +00:00
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function startEdit ( int $index ) : void
{
if ( ! isset ( $this -> domainRows [ $index ]) || ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false )) {
return ;
}
$this -> editingIndex = $index ;
$this -> editingDomain = $this -> domainRows [ $index ][ 'url' ];
$this -> editingServiceApplicationId = ( int ) $this -> domainRows [ $index ][ 'service_application_id' ];
$this -> editDomainDnsFailed = false ;
$this -> editDomainDnsMessage = '' ;
$this -> forceSaveEditDns = false ;
$this -> resetErrorBag ( 'editingDomain' );
$this -> showEditDomainModal = true ;
}
public function cancelEdit () : void
{
$this -> showEditDomainModal = false ;
$this -> editingIndex = null ;
$this -> editingDomain = '' ;
$this -> editingServiceApplicationId = null ;
$this -> editDomainDnsFailed = false ;
$this -> editDomainDnsMessage = '' ;
$this -> forceSaveEditDns = false ;
$this -> resetErrorBag ( 'editingDomain' );
}
public function updateDomain () : void
{
try {
$this -> authorize ( 'update' , $this -> service );
if ( $this -> editingIndex === null || ! isset ( $this -> domainRows [ $this -> editingIndex ])) {
return ;
}
$this -> validateOnly ( 'editingDomain' );
$app = $this -> findServiceApp ( $this -> editingServiceApplicationId );
if ( ! $app ) {
return ;
}
$normalized = ValidationPatterns :: normalizeApplicationDomains ( $this -> editingDomain );
if ( blank ( $normalized ) || count ( $this -> splitDomains ( $normalized )) !== 1 ) {
$this -> addError ( 'editingDomain' , 'Please enter a single valid domain URL.' );
return ;
}
$newUrl = $this -> splitDomains ( $normalized )[ 0 ];
$oldUrl = $this -> domainRows [ $this -> editingIndex ][ 'url' ];
$current = collect ( $this -> splitDomains ( $app -> fqdn ));
if ( $newUrl !== $oldUrl && $current -> contains ( $newUrl )) {
$this -> addError ( 'editingDomain' , " Domain { $newUrl } is already configured for this service. " );
return ;
}
if ( ! $this -> forceSaveEditDns && $this -> shouldValidateDns ()) {
$dnsFailure = $this -> findDnsFailureMessage ([ $newUrl ]);
if ( $dnsFailure !== null ) {
$this -> editDomainDnsFailed = true ;
$this -> editDomainDnsMessage = $dnsFailure ;
2026-08-05 11:50:11 +00:00
$this -> showEditDomainModal = true ;
2026-08-03 21:11:54 +00:00
return ;
}
}
$updated = $current -> map ( fn ( string $url ) => $url === $oldUrl ? $newUrl : $url ) -> unique () -> values ();
$this -> pendingAction = 'update' ;
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainListForApp ( $app , $updated )) {
2026-08-03 21:11:54 +00:00
return ;
}
$this -> cancelEdit ();
2026-08-06 11:45:19 +00:00
$this -> dispatch ( 'edit-domain-saved' );
2026-08-03 21:11:54 +00:00
$this -> forceSaveDomains = false ;
$this -> forceRemovePort = false ;
$this -> pendingAction = null ;
$this -> dispatch ( 'success' , 'Domain updated.' );
$this -> refreshDomains ();
$this -> checkUrlsDns ([ $newUrl ], ( int ) $app -> id );
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function removeDomain ( int $index ) : void
{
try {
$this -> authorize ( 'update' , $this -> service );
if ( ! isset ( $this -> domainRows [ $index ]) || ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false )) {
return ;
}
$app = $this -> findServiceApp (( int ) $this -> domainRows [ $index ][ 'service_application_id' ]);
if ( ! $app ) {
return ;
}
$url = $this -> domainRows [ $index ][ 'url' ];
$updated = collect ( $this -> splitDomains ( $app -> fqdn )) -> reject ( fn ( string $item ) => $item === $url ) -> values ();
$this -> forceSaveDomains = true ;
$this -> forceRemovePort = true ;
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainListForApp ( $app , $updated , checkConflicts : false )) {
2026-08-03 21:11:54 +00:00
return ;
}
$this -> forceSaveDomains = false ;
$this -> forceRemovePort = false ;
$this -> dispatch ( 'success' , 'Domain removed.' );
$this -> pruneDomainDnsStatusesToCurrentDomains ();
2026-08-06 11:18:46 +00:00
$this -> refreshDomains ();
2026-08-03 21:11:54 +00:00
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function addSuggestedDomain ( int $index ) : void
{
try {
$this -> authorize ( 'update' , $this -> service );
if ( ! isset ( $this -> domainRows [ $index ]) || ! ( $this -> domainRows [ $index ][ 'is_suggested' ] ? ? false )) {
return ;
}
$app = $this -> findServiceApp (( int ) $this -> domainRows [ $index ][ 'service_application_id' ]);
if ( ! $app ) {
return ;
}
$url = ( string ) $this -> domainRows [ $index ][ 'url' ];
$normalized = ValidationPatterns :: normalizeApplicationDomains ( $url );
$newUrls = $this -> splitDomains ( $normalized );
$current = collect ( $this -> splitDomains ( $app -> fqdn ));
foreach ( $newUrls as $newUrl ) {
if ( $current -> contains ( $newUrl )) {
$this -> refreshDomains ();
return ;
}
}
$force = $this -> forceAddSuggestedIndex === $index
|| ( bool ) ( $this -> domainRows [ $index ][ 'needs_force_add' ] ? ? false );
if ( ! $force && $this -> shouldValidateDns ()) {
$dnsFailure = $this -> findDnsFailureMessage ( $newUrls );
if ( $dnsFailure !== null ) {
$this -> domainRows [ $index ][ 'dns_status' ] = 'failed' ;
$this -> domainRows [ $index ][ 'dns_message' ] = $dnsFailure ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
$this -> domainRows [ $index ][ 'needs_force_add' ] = true ;
$this -> forceAddSuggestedIndex = $index ;
$this -> editingIndex = $index ;
$this -> persistAllDomainDnsStatuses ();
return ;
}
}
$merged = $current -> merge ( $newUrls ) -> unique () -> values ();
$this -> pendingAction = 'suggested' ;
$this -> editingIndex = $index ;
2026-08-05 11:50:11 +00:00
if ( ! $this -> saveDomainListForApp ( $app , $merged )) {
2026-08-03 21:11:54 +00:00
return ;
}
$this -> forceAddSuggestedIndex = null ;
$this -> editingIndex = null ;
$this -> pendingAction = null ;
$this -> forceSaveDomains = false ;
$this -> forceRemovePort = false ;
$this -> dispatch ( 'success' , 'Domain added.' );
$this -> refreshDomains ();
$this -> checkUrlsDns ( $newUrls , ( int ) $app -> id );
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
public function generateDomain () : void
{
try {
$this -> authorize ( 'update' , $this -> service );
$app = $this -> findServiceApp ( $this -> newServiceApplicationId );
$server = $this -> service -> server ;
if ( ! $app || ! $server ) {
$this -> dispatch ( 'error' , 'Service application or server not found.' );
return ;
}
$domain = generateUrl ( server : $server , random : new_public_id ());
$requiredPort = $app -> getRequiredPort ();
if ( $requiredPort !== null ) {
$parts = parse_url ( $domain );
if ( is_array ( $parts ) && empty ( $parts [ 'port' ])) {
$scheme = $parts [ 'scheme' ] ? ? 'https' ;
$host = $parts [ 'host' ] ? ? '' ;
$path = $parts [ 'path' ] ? ? '' ;
$domain = " { $scheme } :// { $host } : { $requiredPort } { $path } " ;
}
}
$this -> newDomain = $domain ;
$this -> updatedNewDomain ();
} catch ( \Throwable $e ) {
handleError ( $e , $this );
}
}
/**
* @ param Collection < int , string > $domains
*/
protected function saveDomainListForApp (
ServiceApplication $app ,
Collection $domains ,
bool $checkConflicts = true ,
) : bool {
$domainString = $domains -> filter () -> unique () -> implode ( ',' );
$domainString = $domainString === '' ? null : ValidationPatterns :: normalizeApplicationDomains ( $domainString );
if ( $domainString ) {
$errors = ValidationPatterns :: validateApplicationDomains ( $domainString );
if ( $errors !== []) {
$this -> dispatch ( 'error' , $errors [ 0 ]);
return false ;
}
}
$app -> fqdn = $domainString ;
if ( $checkConflicts && ! $this -> forceSaveDomains ) {
$result = checkDomainUsage ( resource : $app );
if ( $result [ 'hasConflicts' ]) {
$this -> domainConflicts = $result [ 'conflicts' ];
$this -> showDomainConflictModal = true ;
$app -> refresh ();
return false ;
}
}
if ( ! $this -> forceRemovePort ) {
$requiredPort = $app -> getRequiredPort ();
if ( $requiredPort !== null && $domainString ) {
foreach ( $this -> splitDomains ( $domainString ) as $fqdn ) {
if ( ServiceApplication :: extractPortFromUrl ( $fqdn ) === null ) {
$this -> requiredPort = $requiredPort ;
$this -> showPortWarningModal = true ;
$app -> refresh ();
return false ;
}
}
}
}
$warning = sslipDomainWarning ( $domainString ? ? '' );
if ( $warning ) {
$this -> dispatch ( 'warning' , __ ( 'warning.sslipdomain' ));
}
DB :: transaction ( function () use ( $app ) : void {
$app -> save ();
updateCompose ( $app );
$this -> service -> parse ();
});
if ( str ( $app -> fqdn ) -> contains ( ',' )) {
$this -> dispatch ( 'warning' , 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.' );
}
$this -> dispatch ( 'configurationChanged' );
return true ;
}
/**
* @ param array < int , string > $urls
*/
/**
* Run a first - time DNS check for newly added / updated domain URLs and persist results .
*
* @ param array < int , string > $urls
*/
protected function checkUrlsDns ( array $urls , ? int $serviceApplicationId = null ) : void
{
if ( $urls === []) {
return ;
}
$urlSet = array_fill_keys ( $urls , true );
$server = $this -> service -> server ;
$skipDns = ! $this -> dnsValidationEnabled || ! $server ;
foreach ( $this -> domainRows as $index => $row ) {
$url = $row [ 'url' ] ? ? null ;
if ( ! is_string ( $url ) || ! isset ( $urlSet [ $url ])) {
continue ;
}
if ( $serviceApplicationId !== null
&& ( int ) ( $row [ 'service_application_id' ] ? ? 0 ) !== $serviceApplicationId ) {
continue ;
}
if ( $skipDns ) {
$this -> domainRows [ $index ][ 'dns_status' ] = 'skipped' ;
$this -> domainRows [ $index ][ 'dns_message' ] = ! $this -> dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: 'No server available for DNS validation.' ;
$this -> domainRows [ $index ][ 'checked_at' ] = now () -> toIso8601String ();
$this -> domainRows [ $index ][ 'expected_ip' ] = $this -> serverIp ;
continue ;
}
$this -> applyDnsStatus ( $index , $url , $server );
}
$this -> persistAllDomainDnsStatuses ();
}
protected function shouldValidateDns () : bool
{
return $this -> dnsValidationEnabled && $this -> service -> server !== null ;
}
/**
* @ param array < int , string > $urls
*/
protected function findDnsFailureMessage ( array $urls ) : ? string
{
$server = $this -> service -> server ;
if ( ! $server ) {
return null ;
}
$target = $this -> dnsTargetLabel () ? ? $server -> ip ;
foreach ( $urls as $url ) {
try {
if ( ! validateDNSEntry ( $url , $server )) {
return dnsMismatchGuidanceMessage ( $target , $this -> serverIp );
}
} catch ( \Throwable ) {
return 'Could not validate DNS for this domain.' ;
}
}
return null ;
}
protected function findServiceApp ( ? int $id ) : ? ServiceApplication
{
if ( ! $id ) {
return null ;
}
return $this -> service -> applications -> firstWhere ( 'id' , $id )
? ? ServiceApplication :: query () -> where ( 'service_id' , $this -> service -> id ) -> find ( $id );
}
protected function domainHost ( string $url ) : ? string
{
try {
$host = parse_url ( $url , PHP_URL_HOST );
return is_string ( $host ) && $host !== '' ? $host : null ;
} catch ( \Throwable ) {
return null ;
}
}
/**
* @ param bool $forRedirectPairing When true , also pair sslip / nip hosts for www↔non - www redirects .
*/
protected function wwwCounterpartUrl ( string $url , bool $forRedirectPairing = false ) : ? string
{
$host = $this -> domainHost ( $url );
if ( $host === null ) {
return null ;
}
$lowerHost = strtolower ( $host );
if ( filter_var ( $host , FILTER_VALIDATE_IP ) !== false || $lowerHost === 'localhost' ) {
return null ;
}
if (
! $forRedirectPairing
&& ( str_contains ( $lowerHost , 'sslip.io' ) || str_contains ( $lowerHost , 'nip.io' ))
) {
return null ;
}
$counterpartHost = str_starts_with ( $lowerHost , 'www.' )
? substr ( $host , 4 )
: 'www.' . $host ;
if ( $counterpartHost === '' || $counterpartHost === $host ) {
return null ;
}
$parts = parse_url ( $url );
if ( $parts === false ) {
return null ;
}
$scheme = $parts [ 'scheme' ] ? ? 'https' ;
$port = isset ( $parts [ 'port' ]) ? ':' . $parts [ 'port' ] : '' ;
$path = $parts [ 'path' ] ? ? '' ;
$query = isset ( $parts [ 'query' ]) ? '?' . $parts [ 'query' ] : '' ;
$fragment = isset ( $parts [ 'fragment' ]) ? '#' . $parts [ 'fragment' ] : '' ;
return " { $scheme } :// { $counterpartHost } { $port } { $path } { $query } { $fragment } " ;
}
public function render ()
{
return view ( 'livewire.project.service.domains' );
}
}