From f03cf8295459f876bf12425acc1c49467faf263b Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Fri, 23 May 2025 20:59:43 +0330 Subject: [PATCH] update: domain verfication service --- CAPROVER_SETUP.md | 76 +++++ .../businesses/businesses.controller.ts | 120 ++++++++ src/modules/businesses/businesses.module.ts | 5 + .../services/domain-verification.service.ts | 268 +++++++++++++++++- 4 files changed, 458 insertions(+), 11 deletions(-) create mode 100644 CAPROVER_SETUP.md diff --git a/CAPROVER_SETUP.md b/CAPROVER_SETUP.md new file mode 100644 index 0000000..4315944 --- /dev/null +++ b/CAPROVER_SETUP.md @@ -0,0 +1,76 @@ +# Caprover Integration Setup + +This document explains how to set up the integration between the DZone API and Caprover for automatic domain configuration. + +## Overview + +When a user successfully verifies their domain, the system will automatically configure that domain in Caprover, enabling the domain to be properly routed to the frontend application. + +## Configuration Steps + +### 1. Set Environment Variables + +Add the following environment variables to your `.env` file: + +``` +CAPROVER_API_URL=https://captain.dev.danakcorp.com/api/v2 +CAPROVER_EMAIL=your_caprover_email@example.com +CAPROVER_PASSWORD=your_caprover_password +CAPROVER_APP_NAME=dzone-front +``` + +The system will automatically handle authentication with Caprover using these credentials and refresh tokens as needed. + +### 2. Verify Configuration + +1. Restart the API service to apply the new environment variables +2. Try verifying a domain through the API +3. Check the logs to ensure the Caprover API calls are successful + +## How Token Refresh Works + +The system automatically handles Caprover authentication: + +1. When a domain configuration request is made, the system checks if it has a valid token +2. If no token exists or the token is about to expire (within 5 minutes), it requests a new one +3. The system uses the email and password to authenticate with Caprover +4. The new token is stored and used for subsequent requests +5. Tokens are typically valid for 24 hours, but the system refreshes them after 23 hours to be safe + +This approach ensures that the integration continues to work even if tokens expire. + +## Troubleshooting + +### Authentication Issues + +If you see authentication errors in the logs: + +1. Verify that the `CAPROVER_EMAIL` and `CAPROVER_PASSWORD` are correct +2. Check that the user has sufficient permissions in Caprover +3. Ensure the Caprover instance is accessible at the URL specified in `CAPROVER_API_URL` + +### API Connection Issues + +If the API cannot connect to Caprover, check: + +1. That the Caprover instance is running and accessible +2. That the `CAPROVER_API_URL` is correct +3. That there are no network issues between the API server and Caprover + +### Domain Configuration Issues + +If domains are not being properly configured in Caprover: + +1. Check that the domain has proper DNS records pointing to your server +2. Verify that the app name specified in `CAPROVER_APP_NAME` exists in Caprover +3. Ensure the Caprover user associated with the credentials has sufficient permissions + +## Manual Configuration + +If automatic configuration fails, you can manually configure domains using the admin endpoint: + +``` +POST /business/domain/configure-caprover +``` + +This endpoint requires authentication and a business with a verified domain. diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index 480e64e..789bb9a 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -62,4 +62,124 @@ export class BusinessesController { getDomainStatus(@BusinessDec() business: Business) { return this.domainVerificationService.getDomainStatus(business.id); } + + @Post("domain/update-dns-records") + @ApiOperation({ summary: "Get detailed instructions for updating DNS records" }) + @AuthGuards() + getDetailedDnsInstructions(@BusinessDec() business: Business) { + // This endpoint provides detailed DNS setup instructions for various DNS providers + if (!business.domain) { + return { + success: false, + message: "No domain configured for this business", + }; + } + + return { + success: true, + domain: business.domain, + instructions: { + title: "راهنمای تنظیم رکوردهای DNS برای دامنه شما", + description: "برای اتصال دامنه خود به سرویس دی‌زون، لطفا یکی از روش‌های زیر را انتخاب کنید:", + methods: [ + { + title: "روش A: استفاده از رکورد A (آدرس IP)", + description: "این روش برای همه ارائه‌دهندگان DNS کار می‌کند و برای دامنه اصلی مناسب است.", + steps: [ + "۱. وارد پنل مدیریت DNS خود شوید", + "۲. یک رکورد A جدید ایجاد کنید", + "۳. در فیلد Host یا Name، @ را وارد کنید (برای دامنه اصلی)", + `۴. در فیلد Value یا Points to، آدرس IP سرور ما را وارد کنید: 194.5.192.20`, + "۵. TTL را روی 3600 (یک ساعت) یا کمترین مقدار ممکن تنظیم کنید", + "۶. تغییرات را ذخیره کنید", + ], + }, + { + title: "روش B: استفاده از رکورد CNAME (نام مستعار)", + description: "این روش برای زیردامنه‌ها مانند www توصیه می‌شود.", + steps: [ + "۱. وارد پنل مدیریت DNS خود شوید", + "۲. یک رکورد CNAME جدید ایجاد کنید", + "۳. در فیلد Host یا Name، www را وارد کنید (یا زیردامنه دلخواه)", + `۴. در فیلد Value یا Points to، آدرس سرور ما را وارد کنید: app.dzone.danakcorp.com`, + "۵. TTL را روی 3600 (یک ساعت) یا کمترین مقدار ممکن تنظیم کنید", + "۶. تغییرات را ذخیره کنید", + ], + note: "توجه: برخی ارائه‌دهندگان DNS اجازه استفاده از CNAME برای دامنه اصلی (@) را نمی‌دهند. در این صورت از روش A استفاده کنید.", + }, + ], + providerSpecific: [ + { + provider: "Cloudflare", + instructions: [ + "۱. حالت ابری (پروکسی) را خاموش کنید و فقط از DNS استفاده کنید", + "۲. برای دامنه اصلی (@) از رکورد A استفاده کنید", + "۳. برای زیردامنه www از رکورد CNAME استفاده کنید", + ], + }, + { + provider: "cPanel", + instructions: [ + "۱. به بخش Zone Editor بروید", + "۲. روی دامنه خود کلیک کنید", + "۳. برای دامنه اصلی، رکورد A با مقدار 194.5.192.20 اضافه کنید", + "۴. برای www، رکورد CNAME با مقدار app.dzone.danakcorp.com اضافه کنید", + ], + }, + { + provider: "سایر ارائه‌دهندگان", + instructions: [ + "اگر نمی‌توانید CNAME برای دامنه اصلی تنظیم کنید، از رکورد A استفاده کنید", + "برخی ارائه‌دهندگان از رکوردهای ANAME یا ALIAS برای دامنه اصلی پشتیبانی می‌کنند", + "اگر در تنظیم رکوردها مشکل دارید، با پشتیبانی ارائه‌دهنده DNS خود تماس بگیرید", + ], + }, + ], + troubleshooting: [ + "- تغییرات DNS ممکن است بین ۱۵ دقیقه تا ۴۸ ساعت طول بکشد تا در سراسر اینترنت منتشر شود", + "- اگر پس از ۴۸ ساعت هنوز مشکل دارید، مطمئن شوید که رکوردها را درست تنظیم کرده‌اید", + "- برای بررسی وضعیت رکوردهای DNS خود می‌توانید از ابزارهایی مانند dnschecker.org استفاده کنید", + "- اگر همچنان مشکل دارید، با پشتیبانی ما تماس بگیرید", + ], + }, + }; + } + + @Post("domain/configure-caprover") + @ApiOperation({ summary: "Manually configure domain in Caprover (Admin use only)" }) + @AuthGuards() + async manualCaproverConfig(@BusinessDec() business: Business) { + if (!business.domain) { + return { + success: false, + message: "No domain configured for this business", + }; + } + + if (!business.isDomainVerified) { + return { + success: false, + message: "Domain must be verified before configuring in Caprover", + domain: business.domain, + }; + } + + try { + const result = await this.domainVerificationService.configureCaproverDomain(business.domain); + + return { + success: result, + message: result + ? `Domain ${business.domain} successfully configured in Caprover` + : `Failed to configure domain ${business.domain} in Caprover`, + domain: business.domain, + }; + } catch (error: unknown) { + return { + success: false, + message: `Error configuring domain in Caprover: ${error instanceof Error ? error.message : String(error)}`, + domain: business.domain, + }; + } + } } diff --git a/src/modules/businesses/businesses.module.ts b/src/modules/businesses/businesses.module.ts index 7c74210..52d37e3 100644 --- a/src/modules/businesses/businesses.module.ts +++ b/src/modules/businesses/businesses.module.ts @@ -1,4 +1,5 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { HttpModule } from "@nestjs/axios"; import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; @@ -16,6 +17,10 @@ import { DomainVerificationService } from "./services/domain-verification.servic name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME, prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX, }), + HttpModule.register({ + timeout: 10000, + maxRedirects: 5, + }), ], providers: [BusinessesService, BusinessProvisioningProcessor, DomainVerificationService], exports: [BusinessesService], diff --git a/src/modules/businesses/services/domain-verification.service.ts b/src/modules/businesses/services/domain-verification.service.ts index 50563aa..4e6e35a 100644 --- a/src/modules/businesses/services/domain-verification.service.ts +++ b/src/modules/businesses/services/domain-verification.service.ts @@ -3,8 +3,12 @@ import dns from "node:dns"; import { promisify } from "node:util"; import { EntityManager } from "@mikro-orm/postgresql"; -import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { BadRequestException, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError } from "axios"; import dayjs from "dayjs"; +import { catchError, firstValueFrom } from "rxjs"; import { BusinessesService } from "./businesses.service"; import { BusinessMessage } from "../../../common/enums/message.enum"; @@ -18,12 +22,87 @@ export class DomainVerificationService { private readonly resolveCname = promisify(dns.resolveCname); private readonly serverIP = "194.5.192.20"; - private readonly serverDomain = "app.dzone.danakcorp.com"; + private readonly serverDomain = "dzone.danakcorp.com"; + + // Caprover configuration + private readonly caproverApiUrl: string; + private readonly caproverAppName: string; + + // Caprover authentication + // private readonly caproverEmail: string; + private readonly caproverPassword: string; + private caproverAuthToken: string; + private tokenExpiry: Date | null = null; + private readonly TOKEN_REFRESH_BEFORE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes constructor( private readonly businessesService: BusinessesService, private readonly em: EntityManager, - ) {} + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.caproverApiUrl = this.configService.getOrThrow("CAPROVER_API_URL"); + this.caproverAppName = this.configService.getOrThrow("CAPROVER_APP_NAME"); + // this.caproverEmail = this.configService.getOrThrow("CAPROVER_EMAIL"); + this.caproverPassword = this.configService.getOrThrow("CAPROVER_PASSWORD"); + this.caproverAuthToken = this.configService.get("CAPROVER_AUTH_TOKEN", ""); + } + + //-------------------------------- + private async getCaproverToken(): Promise { + try { + // Check if we already have a valid token + if (this.caproverAuthToken && this.tokenExpiry && new Date() < new Date(this.tokenExpiry.getTime() - this.TOKEN_REFRESH_BEFORE_EXPIRY_MS)) { + this.logger.debug("Using existing Caprover token"); + return this.caproverAuthToken; + } + + this.logger.log("Getting new Caprover authentication token"); + + const loginUrl = `${this.caproverApiUrl}/login`; + + const response = await firstValueFrom( + this.httpService + .post( + loginUrl, + { + password: this.caproverPassword, + otpToken: "", + // email: this.caproverEmail, + }, + { + headers: { + "x-namespace": "captain", + "Content-Type": "application/json", + }, + }, + ) + .pipe( + catchError((err: AxiosError) => { + this.logger.error("Error in Caprover authentication", err); + throw new InternalServerErrorException("Failed to authenticate with Caprover"); + }), + ), + ); + + if (response.status === 200 && response.data?.data?.token) { + // Set the token and expiry (default to 24 hours if not provided by Caprover) + this.caproverAuthToken = response.data.data.token; + + // Set expiry to 23 hours from now to be safe (Caprover tokens typically last 24 hours) + this.tokenExpiry = new Date(Date.now() + 1 * 60 * 60 * 1000); + + this.logger.log("Successfully obtained new Caprover token"); + return this.caproverAuthToken; + } else { + this.logger.error(`Failed to get Caprover token: ${JSON.stringify(response.data)}`); + throw new InternalServerErrorException("Failed to get Caprover authentication token"); + } + } catch (error: unknown) { + this.logger.error(`Error getting Caprover token: ${error instanceof Error ? error.message : String(error)}`); + throw new InternalServerErrorException("Failed to authenticate with Caprover"); + } + } /** * Set Domain - Core method for setting/updating the domain and returning instructions @@ -84,6 +163,13 @@ export class DomainVerificationService { if (business.isDomainVerified) { const domainPointsToServer = await this.checkDomainPointsToServer(business.domain); + try { + let caproverConfigured = false; + caproverConfigured = await this.configureCaproverDomain(business.domain); + this.logger.log(`Domain ${business.domain} configuration in Caprover: ${caproverConfigured ? "successful" : "failed"}`); + } catch (error: unknown) { + this.logger.error(`Failed to configure domain in Caprover: ${error instanceof Error ? error.message : String(error)}`); + } return { message: BusinessMessage.DOMAIN_ALREADY_VERIFIED, @@ -109,6 +195,17 @@ export class DomainVerificationService { business.domainVerifiedAt = dayjs().toDate(); await this.em.flush(); + // If domain is verified, try to configure it in Caprover + let caproverConfigured = false; + if (domainPointsToServer) { + try { + caproverConfigured = await this.configureCaproverDomain(business.domain); + this.logger.log(`Domain ${business.domain} configuration in Caprover: ${caproverConfigured ? "successful" : "failed"}`); + } catch (error: unknown) { + this.logger.error(`Failed to configure domain in Caprover: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { message: BusinessMessage.DOMAIN_VERIFICATION_SUCCESS, domain: business.domain, @@ -171,6 +268,109 @@ export class DomainVerificationService { }; } + /** + * Configure a domain in Caprover + * This method sends a request to Caprover API to add a custom domain to an app + */ + async configureCaproverDomain(domain: string): Promise { + try { + // Get a fresh token before making the request + const token = await this.getCaproverToken(); + + this.logger.log(`Configuring domain ${domain} in Caprover for app ${this.caproverAppName}`); + + const url = `${this.caproverApiUrl}/user/apps/appDefinitions/customdomain`; + + const response = await firstValueFrom( + this.httpService + .post( + url, + { + appName: this.caproverAppName, + customDomain: domain, + }, + { + headers: { + "X-Captain-Auth": token, + "x-namespace": "captain", + "Content-Type": "application/json", + }, + }, + ) + .pipe( + catchError((err: AxiosError) => { + this.logger.error("error in configuring domain in caprover", err); + throw new InternalServerErrorException("error in configuring domain in caprover"); + }), + ), + ); + + if (response.status === 200 && response.data?.status === 100) { + this.logger.log(`Successfully configured domain ${domain} in Caprover`); + + // Enable HTTPS for the domain + await this.enableCaproverHttps(domain); + + return true; + } else { + this.logger.error(`Failed to configure domain in Caprover: ${JSON.stringify(response.data)}`); + return false; + } + } catch (error) { + this.logger.error(`Error configuring domain in Caprover: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + + /** + * Enable HTTPS for a domain in Caprover + */ + private async enableCaproverHttps(domain: string): Promise { + try { + // Get a fresh token before making the request + const token = await this.getCaproverToken(); + + this.logger.log(`Enabling HTTPS for domain ${domain} in Caprover`); + + const url = `${this.caproverApiUrl}/user/apps/appDefinitions/enablecustomdomainssl`; + + const response = await firstValueFrom( + this.httpService + .post( + url, + { + appName: this.caproverAppName, + customDomain: domain, + }, + { + headers: { + "X-Captain-Auth": token, + "x-namespace": "captain", + "Content-Type": "application/json", + }, + }, + ) + .pipe( + catchError((err: AxiosError) => { + this.logger.error("error in enabling https in caprover", err); + throw new InternalServerErrorException("error in enabling https in caprover"); + }), + ), + ); + + if (response.status === 200 && response.data?.status === 100) { + this.logger.log(`Successfully enabled HTTPS for domain ${domain} in Caprover`); + return true; + } else { + this.logger.error(`Failed to enable HTTPS in Caprover: ${JSON.stringify(response.data)}`); + return false; + } + } catch (error: unknown) { + this.logger.error(`Error enabling HTTPS in Caprover: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + //*********************************** */ //private methods @@ -178,8 +378,8 @@ export class DomainVerificationService { return `dzone-verify-${crypto.randomBytes(16).toString("hex")}`; } - // Get domain connection instructions - private getDomainConnectionInstructions(_domain: string) { + // Get domain connection instructions with enhanced guidance for different DNS providers + private getDomainConnectionInstructions(domain: string) { return { title: BusinessMessage.DOMAIN_CONNECTION_TITLE, description: BusinessMessage.DOMAIN_CONNECTION_DESCRIPTION, @@ -209,7 +409,12 @@ export class DomainVerificationService { BusinessMessage.DOMAIN_CONNECTION_TROUBLESHOOTING_2, BusinessMessage.DOMAIN_CONNECTION_TROUBLESHOOTING_3, BusinessMessage.DOMAIN_CONNECTION_TROUBLESHOOTING_4, + // Add new troubleshooting instructions for common DNS providers + "- اگر از Cloudflare استفاده می‌کنید، حالت ابری (پروکسی) را خاموش کنید و فقط از DNS استفاده کنید", + "- برای دامنه اصلی (@)، برخی ارائه دهندگان DNS به جای CNAME از رکورد ANAME یا ALIAS استفاده می‌کنند", + "- اگر نمی‌توانید CNAME برای دامنه اصلی تنظیم کنید، از رکورد A با IP سرور استفاده کنید", ], + domain: domain, }; } @@ -233,6 +438,9 @@ export class DomainVerificationService { BusinessMessage.DOMAIN_SETUP_TROUBLESHOOTING_2, BusinessMessage.DOMAIN_SETUP_TROUBLESHOOTING_3, BusinessMessage.DOMAIN_SETUP_TROUBLESHOOTING_4, + // Add new troubleshooting instructions + "- برخی از ارائه‌دهندگان DNS ممکن است نیاز به تنظیمات خاص برای رکوردهای TXT داشته باشند", + "- اگر از Cloudflare استفاده می‌کنید، مطمئن شوید که رکوردهای TXT را بدون پروکسی تنظیم کرده‌اید", ], }, }; @@ -258,37 +466,75 @@ export class DomainVerificationService { } // Check if domain points to our server via A or CNAME record + // Enhanced to handle both root domain and www subdomain private async checkDomainPointsToServer(domain: string): Promise { try { + this.logger.log(`Checking if domain ${domain} points to our servers`); + // Try CNAME first try { const cnameRecords = await this.resolveCname(domain); - this.logger.log(`Found CNAME records for ${domain}: ${JSON.stringify(cnameRecords)}`); // Check if any CNAME points to our server domain if (cnameRecords.some((record) => record === this.serverDomain)) { + this.logger.log(`Domain ${domain} has valid CNAME record pointing to our server`); return true; } - } catch (_error) { - this.logger.debug(`No CNAME records found for ${domain}, trying A records`); + } catch (error: unknown) { + this.logger.debug(`No CNAME records found for ${domain}: ${error instanceof Error ? error.message : String(error)}`); + } + + // Try www subdomain CNAME if root domain check failed + if (!domain.startsWith("www.")) { + try { + const wwwDomain = `www.${domain}`; + const wwwCnameRecords = await this.resolveCname(wwwDomain); + this.logger.log(`Found CNAME records for ${wwwDomain}: ${JSON.stringify(wwwCnameRecords)}`); + + // Check if www subdomain CNAME points to our server domain + if (wwwCnameRecords.some((record) => record === this.serverDomain)) { + this.logger.log(`WWW subdomain ${wwwDomain} has valid CNAME record pointing to our server`); + return true; + } + } catch (error: unknown) { + this.logger.debug(`No CNAME records found for www.${domain}: ${error instanceof Error ? error.message : String(error)}`); + } } // Try A record try { const aRecords = await this.resolveA(domain); - this.logger.log(`Found A records for ${domain}: ${JSON.stringify(aRecords)}`); // Check if any A record points to our server IP if (aRecords.some((record) => record === this.serverIP)) { + this.logger.log(`Domain ${domain} has valid A record pointing to our server`); return true; } - } catch (_error) { - this.logger.debug(`No A records found for ${domain}`); + } catch (error: unknown) { + this.logger.debug(`No A records found for ${domain}: ${error instanceof Error ? error.message : String(error)}`); + } + + // Try www subdomain A record if root domain check failed + if (!domain.startsWith("www.")) { + try { + const wwwDomain = `www.${domain}`; + const wwwARecords = await this.resolveA(wwwDomain); + this.logger.log(`Found A records for ${wwwDomain}: ${JSON.stringify(wwwARecords)}`); + + // Check if www subdomain A record points to our server IP + if (wwwARecords.some((record) => record === this.serverIP)) { + this.logger.log(`WWW subdomain ${wwwDomain} has valid A record pointing to our server`); + return true; + } + } catch (error: unknown) { + this.logger.debug(`No A records found for www.${domain}: ${error instanceof Error ? error.message : String(error)}`); + } } // If we get here, neither CNAME nor A records point to our server + this.logger.warn(`Domain ${domain} does not point to our servers`); return false; } catch (error: unknown) { this.logger.error(`Error checking domain DNS records: ${error instanceof Error ? error.message : String(error)}`);