diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index e20bc97..4007cf8 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -21,7 +21,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; import { ApplicationsService } from './applications.service'; import { DomainService } from './domain.service'; -import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto } from './dto/application.dto'; +import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { UserRole, DatabaseType } from '../common/enums'; @@ -267,6 +267,12 @@ export class ApplicationsController { // ── Custom Domain ───────────────────────────────────────────── + @Post('domain/check-dns') + @ApiOperation({ summary: 'Standalone DNS check (no app needed — for deploy wizard)' }) + async checkDnsStandalone(@Body() dto: CheckDnsDto) { + return this.domainService.checkDnsStandalone(dto.domain, dto.appName); + } + @Get(':id/domain') @ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' }) async getDomainInfo(@Param('id') id: string, @Request() req: any) { diff --git a/backend/src/applications/domain.service.ts b/backend/src/applications/domain.service.ts index 9f96028..fdbcc13 100644 --- a/backend/src/applications/domain.service.ts +++ b/backend/src/applications/domain.service.ts @@ -167,6 +167,68 @@ export class DomainService { }; } + async checkDnsStandalone( + domain: string, + appName: string, + ): Promise<{ + verified: boolean; + message: string; + cnameTarget: string; + instructions: string[]; + }> { + const platformDomain = + this.configService.get('platform.domain') || 'apps.cloudhost.ir'; + const cnameTarget = await this.getPlatformCnameTarget(); + const fullPlatformUrl = `${appName}.${platformDomain}`; + + const instructions = [ + `1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`, + `2. Go to DNS management for your domain`, + `3. Add a CNAME record:`, + ` - Name/Host: @ or www (depending on your domain)`, + ` - Type: CNAME`, + ` - Value/Target: ${fullPlatformUrl}`, + `4. If using a root domain (without www), some registrars support CNAME flattening (e.g. Cloudflare). Otherwise use www.`, + `5. Wait 5-30 minutes for DNS propagation (may take up to 48 hours)`, + `6. Click the "Verify DNS" button`, + ]; + + try { + const resolved = await this.resolveDomain(domain); + + const isValid = resolved.some( + (r) => + r === cnameTarget || + r === fullPlatformUrl || + r.endsWith(`.${cnameTarget}`), + ); + + if (isValid) { + return { + verified: true, + message: + 'DNS verification successful! Your domain points to our servers.', + cnameTarget: fullPlatformUrl, + instructions, + }; + } + + return { + verified: false, + message: `DNS records do not point to ${fullPlatformUrl}. Please check your CNAME record and try again.`, + cnameTarget: fullPlatformUrl, + instructions, + }; + } catch (err: any) { + return { + verified: false, + message: `Could not resolve DNS for ${domain}. Make sure the CNAME record is set and DNS has propagated (may take up to 48 hours).`, + cnameTarget: fullPlatformUrl, + instructions, + }; + } + } + private async resolveDomain(domain: string): Promise { const resolver = new dns.promises.Resolver(); resolver.setServers(['8.8.8.8', '1.1.1.1']); diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 65b664b..1c78fbd 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -269,6 +269,19 @@ export class SetCustomDomainDto { domain: string; } +export class CheckDnsDto { + @ApiProperty({ example: 'www.example.com', description: 'The domain to check DNS for' }) + @IsString() + @Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { + message: 'Invalid domain format (e.g. example.com or www.example.com)', + }) + domain: string; + + @ApiProperty({ example: 'my-app', description: 'The application name (used to compute CNAME target)' }) + @IsString() + appName: string; +} + export class ScaleResourcesDto { @ApiPropertyOptional({ example: '100m' }) @IsOptional() diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index f82c02a..9452bd6 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -737,7 +737,11 @@ export default function AppDetailPage() {

- {app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : `${app.subdomain}.${domainInfo?.platformDomain || 'apps.cloudhost.ir'}`} + {app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '} + {app.customDomain && app.customDomainStatus === 'verified' + ? {app.customDomain} + : {app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'} + }

diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 7220fa1..abec13e 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -7,7 +7,7 @@ import api from '@/lib/api'; import { useAuthStore } from '@/lib/store'; import { toast } from 'react-toastify'; import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types'; -import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe } from 'lucide-react'; +import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react'; const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review']; @@ -92,6 +92,8 @@ export default function DeployPage() { // ── Custom Domain ────────────────────────────── const [enableCustomDomain, setEnableCustomDomain] = useState(false); const [customDomainInput, setCustomDomainInput] = useState(''); + const [dnsVerified, setDnsVerified] = useState(false); + const [dnsCheckResult, setDnsCheckResult] = useState<{ verified: boolean; message: string; cnameTarget?: string; instructions?: string[] } | null>(null); const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({ queryKey: ['custom-domain-price'], @@ -99,6 +101,25 @@ export default function DeployPage() { enabled: step >= 2, }); + const dnsCheckMutation = useMutation({ + mutationFn: () => api.post('/applications/domain/check-dns', { + domain: customDomainInput.toLowerCase().trim(), + appName: `${form.name}-${(user?.id || '').split('-')[0]}`, + }).then((r) => r.data), + onSuccess: (data: { verified: boolean; message: string; cnameTarget: string; instructions: string[] }) => { + setDnsCheckResult(data); + if (data.verified) { + setDnsVerified(true); + toast.success('DNS verified! You can proceed.'); + } else { + toast.warning(data.message); + } + }, + onError: () => { + toast.error('Failed to check DNS. Please try again.'); + }, + }); + // Cost calculation for the review step const { data: costData, isLoading: costLoading } = useQuery({ queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain], @@ -413,7 +434,6 @@ export default function DeployPage() { const canNext = () => { if (step === 0) { if (form.name.length < 2) return false; - // WordPress: migrate or public_html mode requires wp-content file if (form.runtime === 'wordpress') { if ((wpMode === 'migrate' || wpMode === 'public_html') && !wpContentFile) return false; } else { @@ -425,6 +445,49 @@ export default function DeployPage() { return true; }; + const [showDnsModal, setShowDnsModal] = useState(false); + const [dnsModalMessage, setDnsModalMessage] = useState(''); + const [checkingDnsOnNext, setCheckingDnsOnNext] = useState(false); + + const handleNext = async () => { + if (step === 1 && enableCustomDomain && customDomainInput.trim()) { + if (dnsVerified) { + setStep(step + 1); + return; + } + const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; + if (!domainRegex.test(customDomainInput.trim())) { + setDnsModalMessage('Invalid domain format. Please use a format like example.com or www.example.com'); + setShowDnsModal(true); + return; + } + setCheckingDnsOnNext(true); + try { + const { data } = await api.post('/applications/domain/check-dns', { + domain: customDomainInput.toLowerCase().trim(), + appName: `${form.name}-${(user?.id || '').split('-')[0]}`, + }); + if (data.verified) { + setDnsVerified(true); + setDnsCheckResult(data); + toast.success('DNS verified!'); + setStep(step + 1); + } else { + setDnsCheckResult(data); + setDnsModalMessage(data.message || 'DNS records are not configured correctly. Please set up the CNAME record and wait for propagation before proceeding.'); + setShowDnsModal(true); + } + } catch { + setDnsModalMessage('Failed to verify DNS. Please check your domain settings and try again.'); + setShowDnsModal(true); + } finally { + setCheckingDnsOnNext(false); + } + return; + } + setStep(step + 1); + }; + return (
@@ -1477,42 +1540,149 @@ export default function DeployPage() {
- {enableCustomDomain && ( -
- - setCustomDomainInput(e.target.value)} - placeholder="example.com or www.example.com" - className="input-field w-full font-mono text-sm" - /> -

- After deployment, configure your DNS records. Instructions will be shown on the app detail page. -

+ {enableCustomDomain && !dnsVerified && ( +
+
+ + { + setCustomDomainInput(e.target.value); + setDnsVerified(false); + setDnsCheckResult(null); + }} + placeholder="example.com or www.example.com" + className="input-field w-full font-mono text-sm" + /> +
+ + {customDomainInput.trim() && /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(customDomainInput.trim()) && ( + <> + {/* DNS Instructions */} +
+

+ + DNS Setup Required +

+
+

1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)

+

2. Go to DNS management for your domain

+

3. Add a CNAME record:

+
+

+ Name/Host: {customDomainInput.startsWith('www.') ? 'www' : '@'} +

+

+ Type: CNAME +

+

+ Value: {form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir +

+
+

4. If using a root domain (without www), use a registrar that supports CNAME flattening (e.g. Cloudflare).

+

5. Wait 5–30 minutes for DNS propagation

+
+ + {/* CNAME target copy box */} +
+

CNAME Target:

+
+ + {form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir + + +
+
+
+ + {/* DNS check result */} + {dnsCheckResult && !dnsCheckResult.verified && ( +
+

+ + {dnsCheckResult.message} +

+
+ )} + + {/* Verify button */} + + +

+ You must verify DNS before proceeding. SSL will be provisioned automatically after deployment. +

+ + )} + + {customDomainInput.trim() && !/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(customDomainInput.trim()) && ( +

+ Invalid domain format. Use a format like example.com or www.example.com +

+ )}
)}
@@ -2086,11 +2256,14 @@ export default function DeployPage() { {step < steps.length - 1 ? ( ) : (
)} + + {/* DNS Verification Error Modal */} + {showDnsModal && ( +
+
setShowDnsModal(false)} + /> +
+
+
+ +
+

DNS Not Verified

+

{dnsModalMessage}

+
+

Required CNAME record:

+ + {customDomainInput} → {form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir + +
+
+
+ +
+
+
+ )}
); }